Skip to content
Merged
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
6 changes: 6 additions & 0 deletions openbot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ LANGGRAPH_IMAGE=openduet-agent-langgraph:latest
# provider before anybody else can reach this: while it is set, every visitor is an administrator.
# With no provider and this line gone, the deployment refuses to start rather than guessing.
OPENBOT_SINGLE_USER=true
# Where the API server and the app listen. Unset, with no sign-in, that is this machine only
# (127.0.0.1 and ::1, plus Docker's bridge on Linux so the Bots' containers can call back), because
# whoever reaches the port is the administrator. With sign-in it is every interface. Set it to listen
# further, for example 0.0.0.0 for every interface, or a comma-separated list of addresses; with no
# sign-in, everybody on that network is then the administrator. The container image sets 0.0.0.0.
# OPENBOT_HOST=0.0.0.0
# Optional desktop employee sign-in authority: an OpenBot deployment configured with a provider
# below. Separate from Intelligence URLs/project credentials; secrets stay on the authority.
# When set, verified employee identity replaces the standalone actor even if SINGLE_USER is true.
Expand Down
5 changes: 5 additions & 0 deletions openbot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ ENV DATABASE_URL=postgres://openbot@127.0.0.1:5432/openbot

ENV NODE_ENV=production
ENV PORT=3001
# Every interface, inside the container. Without it a single-user server listens on loopback
# (shared/listen-host.ts), and a container's loopback is its own: `-p 3001:3001` would publish a
# port nothing answers on. Who can reach the container is then what `-p` publishes it on, so a
# single-user one belongs on `-p 127.0.0.1:3001:3001` (docs/deployment.md).
ENV OPENBOT_HOST=0.0.0.0
EXPOSE 3001

# One port out. The browser's 4100 is deliberately not exposed: it holds real logins and its only
Expand Down
37 changes: 29 additions & 8 deletions openbot/app/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

import { join, normalize, sep } from "node:path";
import { DICTATION_HTTP_IDLE_SECONDS } from "@openbot/shared/dictation";
import {
LISTEN_HOST_VARIABLE,
listenAddress,
listenHosts,
} from "@openbot/shared/listen-host";
import { listenPort } from "@openbot/shared/listen-port";
import { file, type ServerWebSocket } from "bun";

Expand Down Expand Up @@ -192,11 +197,16 @@ function prepareWebSocketBridge(upstream: BunWebSocket, signal: AbortSignal) {
}

if (import.meta.main) {
Bun.serve<WebSocketBridge>({
/*
* Both loopbacks, 127.0.0.1 and ::1, as two listeners, unless OPENBOT_HOST names others
* (shared/listen-host.ts). This was `::`, which its comment called both loopbacks and which is
* every interface: an installed OpenDuet, which is one administrator with no sign-in, proxied its
* `/api` to anybody on the same network.
*/
const hosts = listenHosts(process.env[LISTEN_HOST_VARIABLE], "loopback");
if (!hosts.ok) throw new Error(hosts.reason);
const options: Parameters<typeof Bun.serve<WebSocketBridge>>[0] = {
port: PORT,
// Both loopbacks, which is what `::` gets you: a dual-stack socket answers on 127.0.0.1 and
// ::1 alike. Bound to one, whoever is told the URL has no way to know which they were given.
hostname: "::",
// `ws: true` on the old proxy was required for the live screen, so the upgrade is forwarded
// rather than answered with the app's HTML, which failed with an opaque socket error.
websocket: {
Expand Down Expand Up @@ -270,9 +280,20 @@ if (import.meta.main) {
}
return new Response("not found", { status: 404 });
},
});
};

console.log(
`OpenBot app on http://127.0.0.1:${PORT} and http://[::1]:${PORT}`,
);
const listening: string[] = [];
for (const { host, required } of hosts.hosts) {
try {
Bun.serve<WebSocketBridge>({
...options,
...(host === undefined ? {} : { hostname: host }),
});
listening.push(listenAddress(host, PORT));
} catch (error) {
// Only ::1 may be missing, on a machine with IPv6 switched off.
if (required) throw error;
}
}
console.log(`OpenBot app on ${listening.join(" and ")}`);
}
73 changes: 64 additions & 9 deletions openbot/app/src/components/approvals/approval-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { type ComponentProps, Fragment, type ReactNode, useId } from "react";
import {
type ComponentProps,
Fragment,
type ReactNode,
useId,
useState,
} from "react";
import { DuetIcon, type DuetIconName } from "@/components/duet/icons";
import { DuetCard, OutcomePill, Rule, Tile } from "@/components/duet/kit";
import {
Expand Down Expand Up @@ -196,14 +202,7 @@ function ApprovalContent({
</div>
</Fragment>
))}
{view.body ? (
<>
<Rule />
<p className="type-row max-h-64 overflow-y-auto whitespace-pre-wrap px-4 py-3 leading-6 [overflow-wrap:anywhere]">
{view.body}
</p>
</>
) : null}
{view.body ? <ApprovalBody body={view.body} /> : null}
{request.action.policy?.reason ? (
<p className="type-label px-4 pt-1 pb-1 text-duet-ink-2">
{handOff ? "Handed to you: " : ""}
Expand Down Expand Up @@ -234,6 +233,62 @@ function ApprovalContent({
);
}

/** A body past this many characters, or lines, starts folded with "Show all" under it. */
export const FOLD_CHARS = 600;
export const FOLD_LINES = 10;

/**
* What the Bot would send, all of it, folded when it is long.
*
* It was a box of fixed height that scrolled, over a body the server had already cut to 280
* characters: the card stopped mid-word, nothing said there was more, and Allow once sent the rest
* unseen. The server now sends the whole body (approvals/service.ts `publicApproval`), and a long
* one is folded rather than boxed: the whole text is on the page, cut off visibly at the fold with
* a fade, and "Show all" under it says how much there is and opens it in place. Nothing is ever
* held back from the page, so a screen reader reads it all, folded or not.
*/
function ApprovalBody({ body }: { body: string }) {
const bodyId = useId();
const [open, setOpen] = useState(false);
const long = body.length > FOLD_CHARS || body.split("\n").length > FOLD_LINES;
const folded = long && !open;
return (
<>
<Rule />
<p
className={cn(
"type-row whitespace-pre-wrap px-4 py-3 leading-6 [overflow-wrap:anywhere]",
folded &&
"max-h-60 overflow-hidden [mask-image:linear-gradient(to_bottom,black_65%,transparent)]",
)}
data-folded={folded ? "true" : undefined}
id={bodyId}
>
{body}
</p>
{long ? (
<button
aria-controls={bodyId}
aria-expanded={open}
className="type-label mx-4 mb-1 inline-flex min-h-11 items-center gap-1 rounded-full font-semibold text-duet-ink-2 hover:text-duet-ink focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-accent/40"
onClick={() => setOpen((shown) => !shown)}
type="button"
>
{open
? "Show less"
: `Show all ${body.length.toLocaleString()} characters`}
<DuetIcon
className={open ? "-rotate-90" : "rotate-90"}
name="chevronRight"
size={14}
stroke={2.2}
/>
</button>
) : null}
</>
);
}

function DecidedLine({
children,
at,
Expand Down
16 changes: 6 additions & 10 deletions openbot/app/src/components/channels/first-day-greeting.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FIRST_DAY_REQUEST_WITHOUT_MAIL } from "@openbot/shared/first-day";

/**
* What the Bot says on the person's first day, drawn in place of the message Chat sent for them.
*
Expand Down Expand Up @@ -32,17 +34,11 @@ export const FIRST_DAY_PLAN =

/**
* What Chat asks for on the first day when the primary Bot can't read the person's mail and
* calendar: no promise to read them, what it can do now, and the card that connects them.
*
* A run that asks the Bot to read mail it cannot reach can only end in a refusal under a bubble that
* said it was reading. So this run asks for what it can do, and for the app-access card
* (`show_app_access`), whose Connect is the connection itself. It names the app by its id: asked for
* "Gmail and Google Calendar", the model asked the tool for "Gmail", "Google Calendar" and "Google
* Workspace", none of which is an app here, and no card was drawn. Its words are matched exactly by
* {@link firstDayPlan}, so the transcript draws the plan that goes with it, on every reload.
* calendar, declared in `shared/first-day.ts` (with why it reads as it does) because the server
* builds the message from it. Its words are matched exactly by {@link firstDayPlan}, so the
* transcript draws the plan that goes with it, on every reload.
*/
export const FIRST_DAY_REQUEST_WITHOUT_MAIL =
"Get to know me. My Gmail and Google Calendar aren't connected yet, so don't try to read them. In a few sentences, tell me what you can do for me now. Then show me the card to connect them: it is one app, google-workspace, but don't write that id to me. Say in one line what you'll do once it's connected.";
export { FIRST_DAY_REQUEST_WITHOUT_MAIL };

/** The second bubble when the mail and calendar aren't connected: no promise to read them. */
export const FIRST_DAY_PLAN_WITHOUT_MAIL =
Expand Down
54 changes: 25 additions & 29 deletions openbot/app/src/components/shell/chat-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,10 @@ import {
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { ChannelChat } from "@/components/channels/channel-chat";
import { ConversationView } from "@/components/channels/conversation-view";
import { FIRST_DAY_REQUEST_WITHOUT_MAIL } from "@/components/channels/first-day-greeting";
import { useCheckInNotifications } from "@/components/shell/check-in-notifier";
import { ChatSuggestionProvider } from "@/components/shell/chat-suggestion";
import {
DraftWatch,
FIRST_DAY_REQUEST,
useFirstDayGreeting,
} from "@/components/shell/first-day";
import {
MORNING_BRIEF_REQUEST,
PersonWatch,
useMorningBrief,
} from "@/components/shell/morning-brief";
import { DraftWatch, useFirstDayGreeting } from "@/components/shell/first-day";
import { PersonWatch, useMorningBrief } from "@/components/shell/morning-brief";
import {
seedMessage,
stashFirstMessage,
Expand All @@ -31,9 +22,12 @@ import { botAppQueryOptions } from "@/lib/app-access/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { botAttentionQueryOptions } from "@/lib/bot-lifecycle/queries";
import { CheckInArrivalContext } from "@/lib/channels/check-in";
import { frameFirstDay } from "@/lib/channels/first-day";
import {
type FirstDayGreeting,
firstDayGreeting,
} from "@/lib/channels/first-day";
import { useMarkOpenChannelRead } from "@/lib/channels/mark-read";
import { frameMorningBrief, localDateOf } from "@/lib/channels/morning-brief";
import { localDateOf } from "@/lib/channels/morning-brief";
import { client } from "@/lib/client";
import { ONGOING_SEARCH_LIMIT, ongoingChannel } from "@/lib/channels/ongoing";
import {
Expand Down Expand Up @@ -158,11 +152,8 @@ function OngoingChat({
"brief",
{
method: "POST",
body: {
agentId: bot.id,
date: today,
brief: frameMorningBrief(MORNING_BRIEF_REQUEST),
},
// No text: the server writes the brief's message itself (`morningBriefMessage`).
body: { agentId: bot.id, date: today },
fallback: "Couldn't ask for the morning brief.",
},
);
Expand Down Expand Up @@ -209,7 +200,11 @@ function FirstMessage({
}) {
const queryClient = useQueryClient();
const createChannel = useMutation({
mutationFn: (input: { agentId: string; greeting?: string; date: string }) =>
mutationFn: (input: {
agentId: string;
greeting?: FirstDayGreeting;
date: string;
}) =>
client<{ channel: AgentChannel; claimed: boolean }>(
"/api/channels/first-day",
"start",
Expand All @@ -231,8 +226,12 @@ function FirstMessage({
/** The person has typed into the composer. */
const typed = useRef(false);

/** Sends the first message, from the composer or as the greeting. Throws when it fails. */
const start = async (text: string, greeting = false) => {
/**
* Sends the first message, from the composer or as the greeting, which names which first day it
* is. The server writes the greeting's message from that alone (`firstDayGreeting`); `text` is
* the same message, drawn here until the conversation takes over. Throws when it fails.
*/
const start = async (text: string, greeting?: FirstDayGreeting) => {
if (startingRef.current)
throw new Error(
"The conversation is still starting. Try again in a moment.",
Expand All @@ -250,7 +249,7 @@ function FirstMessage({
start: async (agentId, first) => {
const result = await createChannel.mutateAsync({
agentId,
...(greeting ? { greeting: first } : {}),
...(greeting ? { greeting } : {}),
// The first day takes its own date's morning brief, so none is posted under the
// greeting when Chat is opened again later today (`morningBrief` on the server).
date: localDateOf(new Date()),
Expand Down Expand Up @@ -291,13 +290,10 @@ function FirstMessage({
? null
: `${me.data?.id ?? "unknown"}:${bot.id}`,
clear: () => !begun.current && !typed.current,
greet: () =>
start(
canReadMail
? frameFirstDay(FIRST_DAY_REQUEST)
: frameFirstDay(FIRST_DAY_REQUEST_WITHOUT_MAIL, { mail: false }),
true,
),
greet: () => {
const which = canReadMail ? "with_mail" : "without_mail";
return start(firstDayGreeting(which), which);
},
});

return (
Expand Down
24 changes: 3 additions & 21 deletions openbot/app/src/components/shell/first-day.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,10 @@ import { useChatDrafting } from "@/components/shell/chat-presence";
import { claimFirstDay } from "@/lib/channels/first-day-claim";

/**
* What Chat asks for on the person's first day. It goes out wrapped in the first-day frame
* (`shared/first-day.ts`), which tells the model nobody typed it and the welcome is already on
* screen, and the transcript draws the Bot's greeting in its place. The `first-day` skill in the
* tenant package names the mail and calendar tools it needs, and asks for the same shape.
*
* THREE PARTS, BECAUSE THE TRANSCRIPT DRAWS THEM AS THREE BUBBLES. Asked only to "tell me what you
* think I do", the answer came back as one bubble of 1,400 to 1,900 characters with bold labels and
* three numbered paragraphs, two or three screens at phone width. So the request names the parts,
* their lengths and the closing sentence, and asks for a blank line between them: the transcript
* splits the first day's answer at those lines (`firstDayParts` in chat-transcript.tsx).
*
* NOTHING HERE ABOUT REPLY BUTTONS. Asking for the answer "before any replies to tap" made the model
* reach for `suggest_replies` at once and end on the closing question, five runs of five. The frame
* keeps the model's buttons out of the first day (`NO_BUTTONS`), and the app draws "Sounds right"
* and "Something's off" under the answer itself.
* What Chat asks for on the person's first day, declared in `shared/first-day.ts` (with why it reads
* as it does) because the server builds the message from it and the browser only says which.
*/
export const FIRST_DAY_REQUEST = [
"Get to know me. Look through my recent mail and this week's calendar, then answer in three parts, with a blank line between them.",
"First, in plain sentences with no bold or headings, say who you think I am and what I do, in at most 450 characters.",
'Second, write "How I\'ll help:" and then three bullets, one line each and under 60 characters, each tied to something you saw.',
'Third, end with this sentence on its own: "If any of these sound off, just let me know and I\'ll learn."',
].join(" ");
export { FIRST_DAY_REQUEST } from "@openbot/shared/first-day";

/** The sentence the first day's answer ends on, as the request asks for it. */
export const FIRST_DAY_CLOSE =
Expand Down
12 changes: 4 additions & 8 deletions openbot/app/src/components/shell/morning-brief.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ import {
import { claimMorningBrief } from "@/lib/channels/morning-brief-claim";

/**
* What Chat asks for the first time it opens on a day that is not the first. It goes out wrapped in
* the morning-brief frame (`shared/morning-brief.ts`), which tells the model nobody typed it, to call
* `morning_brief`, and to say at most one sentence after it; the transcript draws nothing where it
* sits, so the Bot's reply and the brief's card are what the person finds.
*
* Plain, because the frame says everything about how to answer. The brief itself is the tool's,
* sorted on the server the way evals/triage measures, and the card is the app's.
* What Chat asks for the first time it opens on a day that is not the first, declared in
* `shared/morning-brief.ts` because the server builds the message from it and takes none from the
* browser.
*/
export const MORNING_BRIEF_REQUEST = "Give me my morning brief.";
export { MORNING_BRIEF_REQUEST } from "@openbot/shared/morning-brief";

/**
* On a day that is not the first, the Bot speaks first: Chat opened on the person's ongoing
Expand Down
Loading
Loading