feat: show routines the bot sets up from chat as one quiet line (#133)
* feat: show routines the bot sets up from chat as one quiet line
When someone mentions something recurring in passing ("every Sunday I plan the
week"), the bot now creates the routine right away and the conversation shows a
single line with the schedule, Edit and Undo, on web and mobile.
- New `event` message block for routine lines, kept in history so later turns
know what was set up or removed.
- schedule_create reuses an active repeating routine of the same name instead
of adding a duplicate, caps a bot at 50 active schedules, and falls back to
default times when none was given.
- Removing a routine (Undo, the editor, or schedule_cancel) marks its lines
removed via the new routines.noticeMessageId column.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1
* test: give the routine notice test a complete job publisher
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d6dd2bef5e
commit
dbf37f86a8
@ -142,6 +142,7 @@ import {
|
|||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
listSpaceUserBotIds,
|
listSpaceUserBotIds,
|
||||||
|
markRoutineNoticesRemoved,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
ownMainAgentsBlockingSpaceDeletion,
|
ownMainAgentsBlockingSpaceDeletion,
|
||||||
@ -2909,6 +2910,8 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
if (!existing) throw new IsolationError();
|
if (!existing) throw new IsolationError();
|
||||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||||
await deps.jobs.cancel(routineJobKey(existing.id));
|
await deps.jobs.cancel(routineJobKey(existing.id));
|
||||||
|
const marked = await markRoutineNoticesRemoved(deps.prisma, existing).catch(() => null);
|
||||||
|
if (marked) await deps.events.notify(marked.threadId, marked.seq).catch(() => undefined);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
testRun: authed.routines.testRun.handler(async ({ context, input }) => {
|
testRun: authed.routines.testRun.handler(async ({ context, input }) => {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
cloudAgentHttpsUrl,
|
cloudAgentHttpsUrl,
|
||||||
deriveWorkspaceFocus,
|
deriveWorkspaceFocus,
|
||||||
isApprovalAskBlock,
|
isApprovalAskBlock,
|
||||||
|
isRoutineEventMessage,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isSecretAskBlock,
|
isSecretAskBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
@ -67,6 +68,7 @@ import {
|
|||||||
type MarkdownArtifactPreviewTarget,
|
type MarkdownArtifactPreviewTarget,
|
||||||
} from "../components/markdown-artifact-preview";
|
} from "../components/markdown-artifact-preview";
|
||||||
import { NativeSymbol } from "../components/native-symbol";
|
import { NativeSymbol } from "../components/native-symbol";
|
||||||
|
import { RoutineEventLine } from "../components/routine-event-line";
|
||||||
import type {
|
import type {
|
||||||
MobileBot,
|
MobileBot,
|
||||||
MobileGroup,
|
MobileGroup,
|
||||||
@ -2400,6 +2402,15 @@ const MessageBubble = memo(function MessageBubble({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isRoutineEventMessage(message.blocks)) {
|
||||||
|
return (
|
||||||
|
<View style={{ width: "100%" }}>
|
||||||
|
{message.blocks.map((block, index) =>
|
||||||
|
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
const handoff = message.blocks.find((block) => block.kind === "handoff");
|
const handoff = message.blocks.find((block) => block.kind === "handoff");
|
||||||
if (handoff) {
|
if (handoff) {
|
||||||
const from = memberName(members, handoff.fromBotId) ?? t("bot");
|
const from = memberName(members, handoff.fromBotId) ?? t("bot");
|
||||||
|
|||||||
105
apps/mobile/components/routine-event-line.tsx
Normal file
105
apps/mobile/components/routine-event-line.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { rpc } from "../lib/api";
|
||||||
|
import { useI18n } from "../lib/i18n";
|
||||||
|
import { useMobileTokens } from "../lib/native";
|
||||||
|
import { routineLineSchedule } from "../lib/routine-line";
|
||||||
|
import { NativeSymbol } from "./native-symbol";
|
||||||
|
|
||||||
|
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
|
||||||
|
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
|
||||||
|
const tokens = useMobileTokens();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const [undoing, setUndoing] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const oneShot = isOneShotRoutineCrons(block.crons);
|
||||||
|
const schedule = routineLineSchedule(block);
|
||||||
|
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
setUndoing(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
await rpc("routines/remove", { routineId: block.routineId });
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setUndoing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
paddingVertical: 4,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
columnGap: 6,
|
||||||
|
rowGap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
accessible
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={oneShot ? t("Reminder") : t("Routine")}
|
||||||
|
>
|
||||||
|
<NativeSymbol
|
||||||
|
ios={oneShot ? "bell" : "repeat"}
|
||||||
|
android={oneShot ? "notifications-outline" : "repeat"}
|
||||||
|
size={14}
|
||||||
|
color={tokens.mutedForeground}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
muted,
|
||||||
|
block.removed
|
||||||
|
? { textDecorationLine: "line-through" }
|
||||||
|
: { color: tokens.foreground, fontWeight: "500" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{block.name}
|
||||||
|
</Text>
|
||||||
|
{schedule ? <Text style={muted}>· {schedule}</Text> : null}
|
||||||
|
{block.removed ? (
|
||||||
|
<Text style={muted}>· {t("Removed")}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{block.event === "routine.updated" ? <Text style={muted}>· {t("Updated")}</Text> : null}
|
||||||
|
<Text style={muted}>·</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
hitSlop={10}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/routine",
|
||||||
|
params: { botId: block.botId, routineId: block.routineId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={[muted, { fontWeight: "600" }]}>{t("Edit")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={muted}>·</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: undoing }}
|
||||||
|
disabled={undoing}
|
||||||
|
hitSlop={10}
|
||||||
|
onPress={() => void undo()}
|
||||||
|
>
|
||||||
|
<Text style={[muted, { fontWeight: "600", opacity: undoing ? 0.5 : 1 }]}>
|
||||||
|
{failed ? t("Try again") : t("Undo")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -901,6 +901,7 @@ export function blockText(message: MobileMessage) {
|
|||||||
return answer ? `${block.question}\n${answer}` : block.question;
|
return answer ? `${block.question}\n${answer}` : block.question;
|
||||||
}
|
}
|
||||||
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||||||
|
if (block.kind === "event") return block.name;
|
||||||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||||||
if (block.kind === "file") {
|
if (block.kind === "file") {
|
||||||
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
||||||
|
|||||||
@ -600,4 +600,18 @@ export const RU_MESSAGES: Record<string, string> = {
|
|||||||
"{name} (permission denied)": "{name} (в разрешении отказано)",
|
"{name} (permission denied)": "{name} (в разрешении отказано)",
|
||||||
"{name} (unsupported type)": "{name} (неподдерживаемый тип)",
|
"{name} (unsupported type)": "{name} (неподдерживаемый тип)",
|
||||||
"Saved {name} locally": "{name} сохранен локально.",
|
"Saved {name} locally": "{name} сохранен локально.",
|
||||||
|
Reminder: "Напоминание",
|
||||||
|
Removed: "Удалено",
|
||||||
|
Updated: "Изменено",
|
||||||
|
Edit: "Изменить",
|
||||||
|
"Try again": "Повторить",
|
||||||
|
Undo: "Отменить",
|
||||||
|
"Every hour": "Каждый час",
|
||||||
|
"Every {count} min": "Каждые {count} мин",
|
||||||
|
"Every {count} h": "Каждые {count} ч",
|
||||||
|
"Daily, {time}": "Ежедневно, {time}",
|
||||||
|
"Weekdays, {time}": "По будням, {time}",
|
||||||
|
"Every {day}, {time}": "Еженедельно: {day}, {time}",
|
||||||
|
"Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}",
|
||||||
|
"Next {date}": "Далее: {date}",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -584,4 +584,18 @@ export const ZH_MESSAGES: Record<string, string> = {
|
|||||||
"Extra high": "超高",
|
"Extra high": "超高",
|
||||||
Low: "低",
|
Low: "低",
|
||||||
High: "高",
|
High: "高",
|
||||||
|
Reminder: "提醒",
|
||||||
|
Removed: "已移除",
|
||||||
|
Updated: "已更新",
|
||||||
|
Edit: "编辑",
|
||||||
|
"Try again": "重试",
|
||||||
|
Undo: "撤销",
|
||||||
|
"Every hour": "每小时",
|
||||||
|
"Every {count} min": "每 {count} 分钟",
|
||||||
|
"Every {count} h": "每 {count} 小时",
|
||||||
|
"Daily, {time}": "每天 {time}",
|
||||||
|
"Weekdays, {time}": "工作日 {time}",
|
||||||
|
"Every {day}, {time}": "每{day} {time}",
|
||||||
|
"Monthly on day {day}, {time}": "每月 {day} 日 {time}",
|
||||||
|
"Next {date}": "下次:{date}",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean {
|
|||||||
block.kind === "handoff" ||
|
block.kind === "handoff" ||
|
||||||
block.kind === "bot_message_sent" ||
|
block.kind === "bot_message_sent" ||
|
||||||
block.kind === "bot_message_received" ||
|
block.kind === "bot_message_received" ||
|
||||||
block.kind === "channel_message",
|
block.kind === "channel_message" ||
|
||||||
|
block.kind === "event",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
apps/mobile/lib/routine-line.ts
Normal file
56
apps/mobile/lib/routine-line.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { routineScheduleShape } from "@rakazo/core";
|
||||||
|
import { dateLocaleForUi, t } from "./i18n";
|
||||||
|
|
||||||
|
/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */
|
||||||
|
const weekdayName = (weekday: number, locale: string) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(
|
||||||
|
new Date(Date.UTC(2024, 0, 7 + weekday)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clock = (hour: number, minute: number, locale: string) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(
|
||||||
|
new Date(Date.UTC(2024, 0, 1, hour, minute)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** When a routine runs, in a few words of the active locale. */
|
||||||
|
export function routineLineSchedule(
|
||||||
|
block: Pick<RoutineEventBlock, "crons" | "nextRunAt" | "timezone">,
|
||||||
|
locale = dateLocaleForUi(),
|
||||||
|
): string {
|
||||||
|
const shape = routineScheduleShape(block.crons);
|
||||||
|
switch (shape.kind) {
|
||||||
|
case "hourly":
|
||||||
|
return t("Every hour");
|
||||||
|
case "minutes":
|
||||||
|
return t("Every {count} min", { count: shape.every });
|
||||||
|
case "hours":
|
||||||
|
return t("Every {count} h", { count: shape.every });
|
||||||
|
case "daily":
|
||||||
|
return t("Daily, {time}", { time: clock(shape.hour, shape.minute, locale) });
|
||||||
|
case "weekdays":
|
||||||
|
return t("Weekdays, {time}", { time: clock(shape.hour, shape.minute, locale) });
|
||||||
|
case "weekly":
|
||||||
|
return t("Every {day}, {time}", {
|
||||||
|
day: weekdayName(shape.weekday, locale),
|
||||||
|
time: clock(shape.hour, shape.minute, locale),
|
||||||
|
});
|
||||||
|
case "monthly":
|
||||||
|
return t("Monthly on day {day}, {time}", {
|
||||||
|
day: shape.day,
|
||||||
|
time: clock(shape.hour, shape.minute, locale),
|
||||||
|
});
|
||||||
|
default: {
|
||||||
|
if (!block.nextRunAt) return "";
|
||||||
|
const date = new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: block.timezone,
|
||||||
|
}).format(new Date(block.nextRunAt));
|
||||||
|
return shape.kind === "once" ? date : t("Next {date}", { date });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/web/e2e/routine-from-text.spec.ts
Normal file
60
apps/web/e2e/routine-from-text.spec.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
import type { Routine } from "@rakazo/contracts";
|
||||||
|
import {
|
||||||
|
activeBotId,
|
||||||
|
captureScreenshot,
|
||||||
|
completeOnboarding,
|
||||||
|
openUserSettings,
|
||||||
|
rpc,
|
||||||
|
signup,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
async function useAppearance(page: Page, appearance: "light" | "dark") {
|
||||||
|
const settings = await openUserSettings(page);
|
||||||
|
await settings.getByTestId(`ui-appearance-${appearance}`).click();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
|
||||||
|
await settings.getByRole("button", { name: "Close user settings" }).click();
|
||||||
|
await expect(settings).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a routine said in passing becomes one quiet line with Edit and Undo", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `routine-line-${stamp}@rakazo.test`, "password12", "Routine Line");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
const botId = activeBotId(page);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(/Message/).fill("Every Sunday I plan the week, keep me on it.");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
const line = page.getByTestId("routine-event-line");
|
||||||
|
await expect(line).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(line).toContainText("Weekly plan");
|
||||||
|
await expect(line).toContainText("Every Sunday, 6:00 PM");
|
||||||
|
await expect(page.getByText("i will remind you every sunday at 6 pm.")).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await useAppearance(page, "light");
|
||||||
|
await line.scrollIntoViewIfNeeded();
|
||||||
|
await captureScreenshot(page, testInfo, "routine-line-light");
|
||||||
|
await useAppearance(page, "dark");
|
||||||
|
await line.scrollIntoViewIfNeeded();
|
||||||
|
await captureScreenshot(page, testInfo, "routine-line-dark");
|
||||||
|
|
||||||
|
await line.getByRole("button", { name: "Edit" }).click();
|
||||||
|
await expect(page.getByPlaceholder("Name this routine")).toHaveValue("Weekly plan", {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
|
||||||
|
await line.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(line).toContainText("Removed", { timeout: 15_000 });
|
||||||
|
await expect(line.getByRole("button", { name: "Undo" })).toHaveCount(0);
|
||||||
|
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await expect(page.getByTestId("routine-event-line")).toContainText("Removed", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -620,5 +620,15 @@
|
|||||||
"Loading voices…": "Stimmen werden geladen…",
|
"Loading voices…": "Stimmen werden geladen…",
|
||||||
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
|
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
|
||||||
"Voice set to {0}.": "Stimme auf {0} gesetzt.",
|
"Voice set to {0}.": "Stimme auf {0} gesetzt.",
|
||||||
"Could not update voice": "Stimme konnte nicht aktualisiert werden"
|
"Could not update voice": "Stimme konnte nicht aktualisiert werden",
|
||||||
|
"Daily, {time}": "Täglich, {time}",
|
||||||
|
"Every {count} h": "Alle {count} Std.",
|
||||||
|
"Every {count} min": "Alle {count} Min.",
|
||||||
|
"Every {day}, {time}": "Jeden {day}, {time}",
|
||||||
|
"Monthly on day {day}, {time}": "Monatlich am {day}., {time}",
|
||||||
|
"Next {date}": "Nächstes Mal {date}",
|
||||||
|
"Reminder": "Erinnerung",
|
||||||
|
"Removed": "Entfernt",
|
||||||
|
"Undo": "Rückgängig",
|
||||||
|
"Weekdays, {time}": "Werktags, {time}"
|
||||||
}
|
}
|
||||||
|
|||||||
35
apps/web/src/lib/routine-line.test.ts
Normal file
35
apps/web/src/lib/routine-line.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { routineLineSchedule } from "./routine-line";
|
||||||
|
|
||||||
|
// The macros compile at build time; here the English source comes back with its values filled in.
|
||||||
|
vi.mock("@lingui/core/macro", () => ({
|
||||||
|
t: (parts: TemplateStringsArray, ...values: unknown[]) =>
|
||||||
|
parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const at = (crons: string[], nextRunAt: string | null = null) => ({
|
||||||
|
crons,
|
||||||
|
nextRunAt,
|
||||||
|
timezone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routineLineSchedule", () => {
|
||||||
|
it("words the common schedules with the locale's clock", () => {
|
||||||
|
expect(routineLineSchedule(at(["0 18 * * 0"]), "de-DE")).toBe("Every Sonntag, 18:00");
|
||||||
|
expect(routineLineSchedule(at(["0 18 * * 0"]), "en-US")).toBe("Every Sunday, 6:00 PM");
|
||||||
|
expect(routineLineSchedule(at(["30 7 * * *"]), "en-US")).toBe("Daily, 7:30 AM");
|
||||||
|
expect(routineLineSchedule(at(["0 8 * * 1-5"]), "de-DE")).toBe("Weekdays, 8:00");
|
||||||
|
expect(routineLineSchedule(at(["0 9 1 * *"]), "en-US")).toBe("Monthly on day 1, 9:00 AM");
|
||||||
|
expect(routineLineSchedule(at(["*/30 * * * *"]), "en-US")).toBe("Every 30 min");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a reminder's time in the routine's zone, and the next run for other schedules", () => {
|
||||||
|
expect(routineLineSchedule(at(["@once"], "2026-10-05T07:00:00.000Z"), "de-DE")).toBe(
|
||||||
|
"Mo., 5. Okt., 9:00",
|
||||||
|
);
|
||||||
|
expect(routineLineSchedule(at(["0 9 * * 1,3"], "2026-10-05T07:00:00.000Z"), "en-US")).toBe(
|
||||||
|
"Next Mon, Oct 5, 9:00 AM",
|
||||||
|
);
|
||||||
|
expect(routineLineSchedule(at(["0 9 * * 1,3"]), "en-US")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/web/src/lib/routine-line.ts
Normal file
67
apps/web/src/lib/routine-line.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { routineScheduleShape } from "@rakazo/core";
|
||||||
|
|
||||||
|
/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */
|
||||||
|
const weekdayName = (weekday: number, locale: string) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(
|
||||||
|
new Date(Date.UTC(2024, 0, 7 + weekday)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clock = (hour: number, minute: number, locale: string) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(
|
||||||
|
new Date(Date.UTC(2024, 0, 1, hour, minute)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextRun = (iso: string, timeZone: string, locale: string) =>
|
||||||
|
new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone,
|
||||||
|
}).format(new Date(iso));
|
||||||
|
|
||||||
|
/** When a routine runs, in a few words of the active locale. */
|
||||||
|
export function routineLineSchedule(
|
||||||
|
block: Pick<RoutineEventBlock, "crons" | "nextRunAt" | "timezone">,
|
||||||
|
locale: string,
|
||||||
|
): string {
|
||||||
|
const shape = routineScheduleShape(block.crons);
|
||||||
|
switch (shape.kind) {
|
||||||
|
case "hourly":
|
||||||
|
return t`Every hour`;
|
||||||
|
case "minutes": {
|
||||||
|
const count = shape.every;
|
||||||
|
return t`Every ${count} min`;
|
||||||
|
}
|
||||||
|
case "hours": {
|
||||||
|
const count = shape.every;
|
||||||
|
return t`Every ${count} h`;
|
||||||
|
}
|
||||||
|
case "daily": {
|
||||||
|
const time = clock(shape.hour, shape.minute, locale);
|
||||||
|
return t`Daily, ${time}`;
|
||||||
|
}
|
||||||
|
case "weekdays": {
|
||||||
|
const time = clock(shape.hour, shape.minute, locale);
|
||||||
|
return t`Weekdays, ${time}`;
|
||||||
|
}
|
||||||
|
case "weekly": {
|
||||||
|
const day = weekdayName(shape.weekday, locale);
|
||||||
|
const time = clock(shape.hour, shape.minute, locale);
|
||||||
|
return t`Every ${day}, ${time}`;
|
||||||
|
}
|
||||||
|
case "monthly": {
|
||||||
|
const day = shape.day;
|
||||||
|
const time = clock(shape.hour, shape.minute, locale);
|
||||||
|
return t`Monthly on day ${day}, ${time}`;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
if (!block.nextRunAt) return "";
|
||||||
|
const date = nextRun(block.nextRunAt, block.timezone, locale);
|
||||||
|
return shape.kind === "once" ? date : t`Next ${date}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1861,6 +1861,10 @@ msgstr "Kundensupport"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Bot-Avatar anpassen"
|
msgstr "Bot-Avatar anpassen"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr "Täglich, {time}"
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Dunkel"
|
msgstr "Dunkel"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "Frühere Nachricht"
|
msgstr "Frühere Nachricht"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Bearbeiten"
|
msgstr "Bearbeiten"
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr "Abend-Check-in"
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "Alle"
|
msgstr "Alle"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr "Alle {count} Std."
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr "Alle {count} Min."
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr "Jeden {day}, {time}"
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Jeden Tag"
|
msgstr "Jeden Tag"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Modelle vom Server"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Monatlich"
|
msgstr "Monatlich"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr "Monatlich am {day}., {time}"
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr "Mehr"
|
msgstr "Mehr"
|
||||||
@ -3175,6 +3197,10 @@ msgstr "Neuer Skill"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "Neuer Space"
|
msgstr "Neuer Space"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr "Nächstes Mal {date}"
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr "Nächster Look"
|
msgstr "Nächster Look"
|
||||||
@ -3828,6 +3854,10 @@ msgstr "Unterbrochenen Computer freigeben?"
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr "Neu laden"
|
msgstr "Neu laden"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr "Erinnerung"
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "Skill {0} entfernen"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "Webhook entfernen"
|
msgstr "Webhook entfernen"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr "Entfernt"
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt."
|
msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt."
|
||||||
@ -4005,6 +4039,7 @@ msgstr "Schlüssel erneuern"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Routine"
|
msgstr "Routine"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr "Triage läuft…"
|
msgstr "Triage läuft…"
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Erneut versuchen"
|
msgstr "Erneut versuchen"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Nicht zugewiesen"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "Nicht verfügbar"
|
msgstr "Nicht verfügbar"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr "Rückgängig"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr "Deinstallieren"
|
msgstr "Deinstallieren"
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr "Update-Fortschritt"
|
msgstr "Update-Fortschritt"
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Aktualisiert"
|
msgstr "Aktualisiert"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "Webhook"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Wochentage"
|
msgstr "Wochentage"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr "Werktags, {time}"
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr "Wöchentlich"
|
msgstr "Wöchentlich"
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr "Customer support"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Customize bot avatar"
|
msgstr "Customize bot avatar"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr "Daily, {time}"
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Dark"
|
msgstr "Dark"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "Earlier message"
|
msgstr "Earlier message"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr "Evening check-in"
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "Every"
|
msgstr "Every"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr "Every {count} h"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr "Every {count} min"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr "Every {day}, {time}"
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "every {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Every day"
|
msgstr "Every day"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Models from server"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Monthly"
|
msgstr "Monthly"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr "Monthly on day {day}, {time}"
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr "More"
|
msgstr "More"
|
||||||
@ -3175,6 +3197,10 @@ msgstr "New skill"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "New space"
|
msgstr "New space"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr "Next {date}"
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr "Next look"
|
msgstr "Next look"
|
||||||
@ -3828,6 +3854,10 @@ msgstr "Release interrupted computer?"
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr "Reload"
|
msgstr "Reload"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr "Reminder"
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "Remove skill {0}"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "Remove webhook"
|
msgstr "Remove webhook"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr "Removed"
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Removed with chat, computer, and memory."
|
msgstr "Removed with chat, computer, and memory."
|
||||||
@ -4005,6 +4039,7 @@ msgstr "Rotate key"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Routine"
|
msgstr "Routine"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr "Triage in progress…"
|
msgstr "Triage in progress…"
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Try again"
|
msgstr "Try again"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Unassigned"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "Unavailable"
|
msgstr "Unavailable"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr "Undo"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr "Uninstall"
|
msgstr "Uninstall"
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr "Update progress"
|
msgstr "Update progress"
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Updated"
|
msgstr "Updated"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "Webhook"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Weekdays"
|
msgstr "Weekdays"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr "Weekdays, {time}"
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr "Weekly"
|
msgstr "Weekly"
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr "Atención al cliente"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Personalizar avatar del bot"
|
msgstr "Personalizar avatar del bot"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Oscuro"
|
msgstr "Oscuro"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "Mensaje anterior"
|
msgstr "Mensaje anterior"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "Cada"
|
msgstr "Cada"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Todos los días"
|
msgstr "Todos los días"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Modelos del servidor"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Mensual"
|
msgstr "Mensual"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "Nueva habilidad"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "Nuevo space"
|
msgstr "Nuevo space"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "Quitar habilidad {0}"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "Quitar webhook"
|
msgstr "Quitar webhook"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Eliminado junto con el chat, la computadora y la memoria."
|
msgstr "Eliminado junto con el chat, la computadora y la memoria."
|
||||||
@ -4005,6 +4039,7 @@ msgstr "Rotar clave"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Rutina"
|
msgstr "Rutina"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Intentarlo de nuevo"
|
msgstr "Intentarlo de nuevo"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Sin asignar"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "No disponible"
|
msgstr "No disponible"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Actualizado"
|
msgstr "Actualizado"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "Webhook"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Días laborables"
|
msgstr "Días laborables"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr ""
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "बॉट अवतार अनुकूलित करें"
|
msgstr "बॉट अवतार अनुकूलित करें"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "डार्क"
|
msgstr "डार्क"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "पुराना संदेश"
|
msgstr "पुराना संदेश"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "हर"
|
msgstr "हर"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "हर {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "हर {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "हर {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "हर दिन"
|
msgstr "हर दिन"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "सर्वर से मॉडल"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "मासिक"
|
msgstr "मासिक"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "नई स्किल"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "स्किल {0} हटाएं"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "चैट, कंप्यूटर और मेमोरी सहित हटाया गया।"
|
msgstr "चैट, कंप्यूटर और मेमोरी सहित हटाया गया।"
|
||||||
@ -4005,6 +4039,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "रूटीन"
|
msgstr "रूटीन"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "फिर से कोशिश करें"
|
msgstr "फिर से कोशिश करें"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "असाइन नहीं किया गया"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr ""
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "कार्यदिवस"
|
msgstr "कार्यदिवस"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr "고객 지원"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "봇 아바타 맞춤설정"
|
msgstr "봇 아바타 맞춤설정"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "다크"
|
msgstr "다크"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "이전 메시지"
|
msgstr "이전 메시지"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "매"
|
msgstr "매"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "{intervalAmountSelect} {intervalUnitSelect}마다"
|
msgstr "{intervalAmountSelect} {intervalUnitSelect}마다"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "{intervalAmountSelect} {intervalUnitSelect}마다"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "매일"
|
msgstr "매일"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "서버에서 찾은 모델"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "매월"
|
msgstr "매월"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "새 스킬"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "새 스페이스"
|
msgstr "새 스페이스"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "작업 기술 {0} 삭제"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "웹훅 제거"
|
msgstr "웹훅 제거"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "대화, 컴퓨터, 기억과 함께 삭제되었습니다."
|
msgstr "대화, 컴퓨터, 기억과 함께 삭제되었습니다."
|
||||||
@ -4005,6 +4039,7 @@ msgstr "key 교체"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "자동 실행"
|
msgstr "자동 실행"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "다시 시도"
|
msgstr "다시 시도"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "섹션 없음"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "사용할 수 없음"
|
msgstr "사용할 수 없음"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "업데이트됨"
|
msgstr "업데이트됨"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "웹훅"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "주중"
|
msgstr "주중"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr ""
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Personalizar avatar do bot"
|
msgstr "Personalizar avatar do bot"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Escuro"
|
msgstr "Escuro"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "A cada"
|
msgstr "A cada"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Todos os dias"
|
msgstr "Todos os dias"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Modelos do servidor"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Mensalmente"
|
msgstr "Mensalmente"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "Nova habilidade"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "Remover habilidade {0}"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Removido com chat, computador e memória."
|
msgstr "Removido com chat, computador e memória."
|
||||||
@ -4005,6 +4039,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Rotina"
|
msgstr "Rotina"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Tentar novamente"
|
msgstr "Tentar novamente"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Não atribuído"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr ""
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Dias da semana"
|
msgstr "Dias da semana"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr "Поддержка клиентов"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Настроить аватар бота"
|
msgstr "Настроить аватар бота"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Тёмная"
|
msgstr "Тёмная"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "Предыдущее сообщение"
|
msgstr "Предыдущее сообщение"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "Каждые"
|
msgstr "Каждые"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Каждый день"
|
msgstr "Каждый день"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Модели с сервера"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Ежемесячно"
|
msgstr "Ежемесячно"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr "Ещё"
|
msgstr "Ещё"
|
||||||
@ -3175,6 +3197,10 @@ msgstr "Новый навык"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "Новое пространство"
|
msgstr "Новое пространство"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr "Освободить прерванный компьютер?"
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "Удалить навык {0}"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "Удалить вебхук"
|
msgstr "Удалить вебхук"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Удалено вместе с чатом, компьютером и памятью."
|
msgstr "Удалено вместе с чатом, компьютером и памятью."
|
||||||
@ -4005,6 +4039,7 @@ msgstr "Сменить ключ"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Задача"
|
msgstr "Задача"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Без раздела"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "Недоступно"
|
msgstr "Недоступно"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr "Удалить"
|
msgstr "Удалить"
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr "Ход обновления"
|
msgstr "Ход обновления"
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Обновлено"
|
msgstr "Обновлено"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "Вебхук"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Будни"
|
msgstr "Будни"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr ""
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "Bot avatarını özelleştir"
|
msgstr "Bot avatarını özelleştir"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "Koyu"
|
msgstr "Koyu"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "Her"
|
msgstr "Her"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "her {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "her {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "her {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "Her gün"
|
msgstr "Her gün"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "Sunucudan gelen modeller"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Aylık"
|
msgstr "Aylık"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "Yeni beceri"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "{0} becerisini kaldır"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "Sohbeti, bilgisayarı ve hafızasıyla birlikte kaldırıldı."
|
msgstr "Sohbeti, bilgisayarı ve hafızasıyla birlikte kaldırıldı."
|
||||||
@ -4005,6 +4039,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "Rutin"
|
msgstr "Rutin"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "Tekrar dene"
|
msgstr "Tekrar dene"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "Atanmamış"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr ""
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "Hafta içi"
|
msgstr "Hafta içi"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1861,6 +1861,10 @@ msgstr "客户支持"
|
|||||||
msgid "Customize bot avatar"
|
msgid "Customize bot avatar"
|
||||||
msgstr "自定义机器人头像"
|
msgstr "自定义机器人头像"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Daily, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/AccountSettingsOverlay.tsx
|
#: src/pages/AccountSettingsOverlay.tsx
|
||||||
msgid "Dark"
|
msgid "Dark"
|
||||||
msgstr "深色"
|
msgstr "深色"
|
||||||
@ -2129,6 +2133,7 @@ msgid "Earlier message"
|
|||||||
msgstr "更早的消息"
|
msgstr "更早的消息"
|
||||||
|
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -2194,6 +2199,18 @@ msgstr ""
|
|||||||
msgid "Every"
|
msgid "Every"
|
||||||
msgstr "每"
|
msgstr "每"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} h"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {count} min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Every {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
msgstr "每 {intervalAmountSelect} {intervalUnitSelect}"
|
msgstr "每 {intervalAmountSelect} {intervalUnitSelect}"
|
||||||
@ -2203,6 +2220,7 @@ msgstr "每 {intervalAmountSelect} {intervalUnitSelect}"
|
|||||||
msgid "Every day"
|
msgid "Every day"
|
||||||
msgstr "每天"
|
msgstr "每天"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/RoutineSchedule.tsx
|
#: src/pages/RoutineSchedule.tsx
|
||||||
msgid "Every hour"
|
msgid "Every hour"
|
||||||
@ -3053,6 +3071,10 @@ msgstr "来自服务器的模型"
|
|||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "每月"
|
msgstr "每月"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Monthly on day {day}, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "More"
|
msgid "More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3175,6 +3197,10 @@ msgstr "新建技能"
|
|||||||
msgid "New space"
|
msgid "New space"
|
||||||
msgstr "新建工作区"
|
msgstr "新建工作区"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Next {date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/onboarding/LookPreviewCarousel.tsx
|
#: src/components/onboarding/LookPreviewCarousel.tsx
|
||||||
msgid "Next look"
|
msgid "Next look"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -3828,6 +3854,10 @@ msgstr ""
|
|||||||
msgid "Reload"
|
msgid "Reload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Reminder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ApprovalRulesSettings.tsx
|
#: src/components/ApprovalRulesSettings.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
@ -3866,6 +3896,10 @@ msgstr "移除技能 {0}"
|
|||||||
msgid "Remove webhook"
|
msgid "Remove webhook"
|
||||||
msgstr "移除 Webhook"
|
msgstr "移除 Webhook"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Removed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/transcript.tsx
|
#: src/pages/shell/transcript.tsx
|
||||||
msgid "Removed with chat, computer, and memory."
|
msgid "Removed with chat, computer, and memory."
|
||||||
msgstr "已连同对话、电脑和记忆一起移除。"
|
msgstr "已连同对话、电脑和记忆一起移除。"
|
||||||
@ -4005,6 +4039,7 @@ msgstr "轮换密钥"
|
|||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
#: src/pages/Shell.tsx
|
#: src/pages/Shell.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Routine"
|
msgid "Routine"
|
||||||
msgstr "例行任务"
|
msgstr "例行任务"
|
||||||
|
|
||||||
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/Onboarding.tsx
|
#: src/pages/Onboarding.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Try again"
|
msgid "Try again"
|
||||||
msgstr "重试"
|
msgstr "重试"
|
||||||
|
|
||||||
@ -4773,6 +4809,10 @@ msgstr "未分配"
|
|||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr "不可用"
|
msgstr "不可用"
|
||||||
|
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
|
msgid "Undo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Uninstall"
|
msgid "Uninstall"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4829,6 +4869,7 @@ msgid "Update progress"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/SoftwareUpdateSection.tsx
|
#: src/components/SoftwareUpdateSection.tsx
|
||||||
|
#: src/pages/shell/routine-event-line.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "已更新"
|
msgstr "已更新"
|
||||||
|
|
||||||
@ -4962,6 +5003,10 @@ msgstr "Webhook"
|
|||||||
msgid "Weekdays"
|
msgid "Weekdays"
|
||||||
msgstr "工作日"
|
msgstr "工作日"
|
||||||
|
|
||||||
|
#: src/lib/routine-line.ts
|
||||||
|
msgid "Weekdays, {time}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/heute/CoachRhythmControl.tsx
|
#: src/components/heute/CoachRhythmControl.tsx
|
||||||
msgid "Weekly"
|
msgid "Weekly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -1524,7 +1524,7 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (routineId && routinesBotId === active.id) {
|
if (routineId && routinesBotId === active.id) {
|
||||||
const routine = routines.find((item) => item.id === routineId);
|
const openRoutine = (routine: Routine | undefined) => {
|
||||||
if (routine) {
|
if (routine) {
|
||||||
setRoutineDraft(draftFromRoutine(routine));
|
setRoutineDraft(draftFromRoutine(routine));
|
||||||
setRoutineWebhookSecret(null);
|
setRoutineWebhookSecret(null);
|
||||||
@ -1533,6 +1533,22 @@ export function ShellPage() {
|
|||||||
} else {
|
} else {
|
||||||
setPanel("computer");
|
setPanel("computer");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const routine = routines.find((item) => item.id === routineId);
|
||||||
|
if (routine) {
|
||||||
|
openRoutine(routine);
|
||||||
|
} else {
|
||||||
|
// The bot may have set it up after the list was loaded.
|
||||||
|
const botId = active.id;
|
||||||
|
void rpc.routines
|
||||||
|
.list({ botId })
|
||||||
|
.then((fresh) => {
|
||||||
|
if (activeBotId.current !== botId) return;
|
||||||
|
setRoutines(fresh);
|
||||||
|
openRoutine(fresh.find((item) => item.id === routineId));
|
||||||
|
})
|
||||||
|
.catch(() => setPanel("computer"));
|
||||||
|
}
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
next.delete("routine");
|
next.delete("routine");
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
|
|||||||
84
apps/web/src/pages/shell/routine-event-line.tsx
Normal file
84
apps/web/src/pages/shell/routine-event-line.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||||
|
import { Icon } from "@rakazo/ui-web";
|
||||||
|
import { BellRing, Repeat } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { routineLineSchedule } from "../../lib/routine-line";
|
||||||
|
import { rpc } from "../../lib/rpc";
|
||||||
|
|
||||||
|
const actionClass =
|
||||||
|
"rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
|
||||||
|
|
||||||
|
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
|
||||||
|
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
|
||||||
|
const { i18n, t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [undoing, setUndoing] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const oneShot = isOneShotRoutineCrons(block.crons);
|
||||||
|
const schedule = routineLineSchedule(block, i18n.locale || "en");
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
setUndoing(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
await rpc.routines.remove({ routineId: block.routineId });
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setUndoing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="routine-event-line"
|
||||||
|
className="flex justify-center px-4 py-1.5 text-center text-ui-label text-muted-foreground"
|
||||||
|
>
|
||||||
|
<p className="inline-flex max-w-full flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5">
|
||||||
|
<Icon
|
||||||
|
icon={oneShot ? BellRing : Repeat}
|
||||||
|
size="sm"
|
||||||
|
role="img"
|
||||||
|
aria-hidden={false}
|
||||||
|
aria-label={oneShot ? t`Reminder` : t`Routine`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
dir="auto"
|
||||||
|
className={block.removed ? "line-through" : "font-medium text-foreground/80"}
|
||||||
|
>
|
||||||
|
{block.name}
|
||||||
|
</span>
|
||||||
|
{schedule ? <span aria-hidden>·</span> : null}
|
||||||
|
{schedule ? <span>{schedule}</span> : null}
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
{block.removed ? (
|
||||||
|
<span>{t`Removed`}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{block.event === "routine.updated" ? <span>{t`Updated`}</span> : null}
|
||||||
|
{block.event === "routine.updated" ? <span aria-hidden>·</span> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={actionClass}
|
||||||
|
onClick={() => navigate(`/app/${block.botId}?routine=${block.routineId}`)}
|
||||||
|
>
|
||||||
|
{t`Edit`}
|
||||||
|
</button>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={actionClass}
|
||||||
|
disabled={undoing}
|
||||||
|
onClick={() => void undo()}
|
||||||
|
>
|
||||||
|
{failed ? t`Try again` : t`Undo`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,12 @@ import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
|||||||
import type { MessageReaction, ThreadMessage } from "@rakazo/contracts";
|
import type { MessageReaction, ThreadMessage } from "@rakazo/contracts";
|
||||||
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
|
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
|
||||||
import type { WorkspaceArtifact } from "@rakazo/core";
|
import type { WorkspaceArtifact } from "@rakazo/core";
|
||||||
import { isPeerReceiptBlocks, isToolActivityBlock, projectMessageReactions } from "@rakazo/core";
|
import {
|
||||||
|
isPeerReceiptBlocks,
|
||||||
|
isRoutineEventMessage,
|
||||||
|
isToolActivityBlock,
|
||||||
|
projectMessageReactions,
|
||||||
|
} from "@rakazo/core";
|
||||||
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
||||||
import {
|
import {
|
||||||
BotAvatar,
|
BotAvatar,
|
||||||
@ -47,6 +52,7 @@ import {
|
|||||||
McpApprovalCard,
|
McpApprovalCard,
|
||||||
} from "./message-cards";
|
} from "./message-cards";
|
||||||
import { previewMessageText } from "./message-preview";
|
import { previewMessageText } from "./message-preview";
|
||||||
|
import { RoutineEventLine } from "./routine-event-line";
|
||||||
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
||||||
|
|
||||||
export const Transcript = memo(function Transcript({
|
export const Transcript = memo(function Transcript({
|
||||||
@ -368,6 +374,15 @@ export const Transcript = memo(function Transcript({
|
|||||||
{reactionView.visibleMessages.map((message) => {
|
{reactionView.visibleMessages.map((message) => {
|
||||||
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
||||||
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
||||||
|
if (isRoutineEventMessage(message.blocks)) {
|
||||||
|
return (
|
||||||
|
<div key={message.id} data-message-id={message.id}>
|
||||||
|
{message.blocks.map((block, index) =>
|
||||||
|
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const messageReactions = reactionView.reactions.get(message.id);
|
const messageReactions = reactionView.reactions.get(message.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -725,7 +725,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
{
|
{
|
||||||
name: "schedule_create",
|
name: "schedule_create",
|
||||||
description:
|
description:
|
||||||
'Create a reminder or recurring job for this bot. Use for "remind me in 10 minutes" or "every morning send a joke". Repeats: cron or every/unit (min 1 minute). One-shot: runAt, delayMinutes, or delaySeconds.',
|
'Create a reminder or recurring job for this bot. Use it whenever the user describes something that should happen later or again, even in passing ("remind me in 10 minutes", "every Sunday I plan the week"): create it right away instead of asking first; the user sees it in the conversation and can undo it. Without a time, use 08:00 for mornings, 19:00 for evenings and 09:00 otherwise. Reusing the name of an active repeating schedule changes that schedule instead of adding another. Repeats: cron or every/unit (min 1 minute). One-shot: runAt, delayMinutes, or delaySeconds.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@ -299,7 +299,11 @@ describe("bot capabilities in runs", () => {
|
|||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
chatGroup: { findFirst: vi.fn(async () => null) },
|
chatGroup: { findFirst: vi.fn(async () => null) },
|
||||||
routine: { findMany: vi.fn(async () => []), create: routineCreate },
|
routine: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
count: vi.fn(async () => 0),
|
||||||
|
create: routineCreate,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" });
|
expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" });
|
||||||
|
|||||||
@ -2785,6 +2785,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
delayMinutes: args.delayMinutes,
|
delayMinutes: args.delayMinutes,
|
||||||
delaySeconds: args.delaySeconds,
|
delaySeconds: args.delaySeconds,
|
||||||
},
|
},
|
||||||
|
notice: { threadId: thread.id, botId: bot.id },
|
||||||
});
|
});
|
||||||
return finish(created);
|
return finish(created);
|
||||||
}
|
}
|
||||||
|
|||||||
144
packages/adapters/src/schedule-notices.postgres.test.ts
Normal file
144
packages/adapters/src/schedule-notices.postgres.test.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { MessageBlock } from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
bootstrapUserSpace,
|
||||||
|
createDb,
|
||||||
|
ensureMainConversation,
|
||||||
|
markRoutineNoticesRemoved,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cancelScheduleFromTool, createScheduleFromTool } from "./schedule-tools.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||||
|
|
||||||
|
describePostgres("routine lines in the conversation (PostgreSQL)", () => {
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
const userIds: string[] = [];
|
||||||
|
|
||||||
|
async function newConversation() {
|
||||||
|
const id = `sn${randomUUID().replaceAll("-", "")}`;
|
||||||
|
userIds.push(id);
|
||||||
|
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
|
||||||
|
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
|
||||||
|
await prisma.userProfile.create({ data: { userId: id, timeZone: "Europe/Berlin" } });
|
||||||
|
const conversation = await ensureMainConversation(prisma, id);
|
||||||
|
const notify = vi.fn(async () => undefined);
|
||||||
|
const deps = {
|
||||||
|
prisma,
|
||||||
|
events: { append: vi.fn(async () => undefined), notify } as unknown as ThreadEvents,
|
||||||
|
jobs: {
|
||||||
|
enqueue: vi.fn(async () => undefined),
|
||||||
|
cancel: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const owner = {
|
||||||
|
spaceId: conversation.spaceId,
|
||||||
|
botId: conversation.leadBotId,
|
||||||
|
userId: id,
|
||||||
|
threadId: conversation.threadId,
|
||||||
|
notice: { threadId: conversation.threadId, botId: conversation.leadBotId },
|
||||||
|
};
|
||||||
|
return { deps, owner, notify, conversation };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lines(threadId: string) {
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: { threadId },
|
||||||
|
orderBy: { seq: "asc" },
|
||||||
|
select: { role: true, blocks: true },
|
||||||
|
});
|
||||||
|
return messages.flatMap((message) =>
|
||||||
|
(message.blocks as MessageBlock[]).filter((block) => block.kind === "event"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const db = createDb(databaseUrl!);
|
||||||
|
prisma = db.prisma;
|
||||||
|
close = async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await db.pool.end();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
const memberships = await prisma.member.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
await prisma.organization.deleteMany({
|
||||||
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a line, changes the routine of the same name, and marks the lines when it is cancelled", async () => {
|
||||||
|
const { deps, owner, notify, conversation } = await newConversation();
|
||||||
|
|
||||||
|
const created = await createScheduleFromTool(deps, {
|
||||||
|
...owner,
|
||||||
|
name: "Wochenplan",
|
||||||
|
prompt: "Plan the week with me",
|
||||||
|
schedule: { cron: "0 18 * * 0" },
|
||||||
|
});
|
||||||
|
expect(created).toMatchObject({ ok: true, cron: "0 18 * * 0" });
|
||||||
|
const changed = await createScheduleFromTool(deps, {
|
||||||
|
...owner,
|
||||||
|
name: "wochenplan",
|
||||||
|
prompt: "Plan the week with me on Saturday",
|
||||||
|
schedule: { cron: "0 10 * * 6" },
|
||||||
|
});
|
||||||
|
expect(changed).toMatchObject({ ok: true, updated: true, cron: "0 10 * * 6" });
|
||||||
|
|
||||||
|
const routines = await prisma.routine.findMany({ where: { userId: owner.userId } });
|
||||||
|
expect(routines.filter((routine) => routine.coachKey === null)).toHaveLength(1);
|
||||||
|
const routine = routines.find((row) => row.name === "Wochenplan")!;
|
||||||
|
expect(routine).toMatchObject({ crons: ["0 10 * * 6"], timezone: "Europe/Berlin" });
|
||||||
|
expect(await lines(conversation.threadId)).toEqual([
|
||||||
|
expect.objectContaining({ event: "routine.created", crons: ["0 18 * * 0"] }),
|
||||||
|
expect.objectContaining({ event: "routine.updated", crons: ["0 10 * * 6"] }),
|
||||||
|
]);
|
||||||
|
expect(notify).toHaveBeenCalledTimes(2);
|
||||||
|
const thread = await prisma.thread.findUniqueOrThrow({ where: { id: conversation.threadId } });
|
||||||
|
expect(thread.unread).toBe(false);
|
||||||
|
|
||||||
|
const cancelled = await cancelScheduleFromTool(deps, { ...owner, name: "Wochenplan" });
|
||||||
|
expect(cancelled).toMatchObject({ ok: true });
|
||||||
|
expect((await lines(conversation.threadId)).map((line) => line.removed)).toEqual([true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one-shot reminders of the same name apart", async () => {
|
||||||
|
const { deps, owner } = await newConversation();
|
||||||
|
for (const delayMinutes of [10, 20]) {
|
||||||
|
await createScheduleFromTool(deps, {
|
||||||
|
...owner,
|
||||||
|
name: "Call mum",
|
||||||
|
prompt: "Remind me to call mum",
|
||||||
|
schedule: { delayMinutes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(await prisma.routine.count({ where: { userId: owner.userId, name: "Call mum" } })).toBe(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for a routine without a line", async () => {
|
||||||
|
const { owner } = await newConversation();
|
||||||
|
expect(
|
||||||
|
await markRoutineNoticesRemoved(prisma, {
|
||||||
|
id: "missing",
|
||||||
|
botId: owner.botId,
|
||||||
|
noticeMessageId: null,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
filterBuiltinToolsForThread,
|
filterBuiltinToolsForThread,
|
||||||
isOneShotRoutineCron,
|
isOneShotRoutineCron,
|
||||||
listSchedulesFromTool,
|
listSchedulesFromTool,
|
||||||
|
MAX_ACTIVE_ROUTINES_PER_BOT,
|
||||||
resolveScheduleOwner,
|
resolveScheduleOwner,
|
||||||
resolveScheduleTiming,
|
resolveScheduleTiming,
|
||||||
SCHEDULE_TOOL_NAMES,
|
SCHEDULE_TOOL_NAMES,
|
||||||
@ -169,7 +170,10 @@ describe("schedule tool persistence", () => {
|
|||||||
nextRunAt: args.data.nextRunAt,
|
nextRunAt: args.data.nextRunAt,
|
||||||
}));
|
}));
|
||||||
const deps = {
|
const deps = {
|
||||||
prisma: { routine: { create }, userProfile: { findUnique: async () => null } },
|
prisma: {
|
||||||
|
routine: { create, findFirst: async () => null, count: async () => 0 },
|
||||||
|
userProfile: { findUnique: async () => null },
|
||||||
|
},
|
||||||
events: { append },
|
events: { append },
|
||||||
jobs: { enqueue, cancel: vi.fn(async () => undefined) },
|
jobs: { enqueue, cancel: vi.fn(async () => undefined) },
|
||||||
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
||||||
@ -202,6 +206,36 @@ describe("schedule tool persistence", () => {
|
|||||||
expect(enqueue).toHaveBeenCalledOnce();
|
expect(enqueue).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses another schedule once the bot has the maximum active", async () => {
|
||||||
|
const create = vi.fn();
|
||||||
|
const deps = {
|
||||||
|
prisma: {
|
||||||
|
routine: {
|
||||||
|
create,
|
||||||
|
findFirst: async () => null,
|
||||||
|
count: async () => MAX_ACTIVE_ROUTINES_PER_BOT,
|
||||||
|
},
|
||||||
|
userProfile: { findUnique: async () => null },
|
||||||
|
},
|
||||||
|
events: { append: vi.fn(async () => undefined) },
|
||||||
|
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
||||||
|
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
||||||
|
|
||||||
|
const result = await createScheduleFromTool(deps, {
|
||||||
|
spaceId: "ws-1",
|
||||||
|
botId: "bot-1",
|
||||||
|
userId: "user-1",
|
||||||
|
threadId: "thread-1",
|
||||||
|
name: "One more",
|
||||||
|
prompt: "Do it",
|
||||||
|
schedule: { cron: "0 9 * * *" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`,
|
||||||
|
});
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults the routine time zone to the user's and lets an explicit zone win", async () => {
|
it("defaults the routine time zone to the user's and lets an explicit zone win", async () => {
|
||||||
const create = vi.fn(async (args: { data: Record<string, unknown> }) => ({
|
const create = vi.fn(async (args: { data: Record<string, unknown> }) => ({
|
||||||
id: "routine-1",
|
id: "routine-1",
|
||||||
@ -209,7 +243,10 @@ describe("schedule tool persistence", () => {
|
|||||||
}));
|
}));
|
||||||
const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" }));
|
const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" }));
|
||||||
const deps = {
|
const deps = {
|
||||||
prisma: { routine: { create }, userProfile: { findUnique } },
|
prisma: {
|
||||||
|
routine: { create, findFirst: async () => null, count: async () => 0 },
|
||||||
|
userProfile: { findUnique },
|
||||||
|
},
|
||||||
events: { append: vi.fn(async () => undefined) },
|
events: { append: vi.fn(async () => undefined) },
|
||||||
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
||||||
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
||||||
@ -250,7 +287,7 @@ describe("schedule tool persistence", () => {
|
|||||||
}));
|
}));
|
||||||
const deps = {
|
const deps = {
|
||||||
prisma: {
|
prisma: {
|
||||||
routine: { create },
|
routine: { create, findFirst: async () => null, count: async () => 0 },
|
||||||
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) },
|
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) },
|
||||||
},
|
},
|
||||||
events: { append: vi.fn(async () => undefined) },
|
events: { append: vi.fn(async () => undefined) },
|
||||||
@ -278,6 +315,8 @@ describe("schedule tool persistence", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
userProfile: { findUnique: async () => null },
|
userProfile: { findUnique: async () => null },
|
||||||
routine: {
|
routine: {
|
||||||
|
findFirst: async () => null,
|
||||||
|
count: async () => 0,
|
||||||
create: vi.fn(async () => ({
|
create: vi.fn(async () => ({
|
||||||
id: "routine-1",
|
id: "routine-1",
|
||||||
name: "Morning joke",
|
name: "Morning joke",
|
||||||
@ -316,6 +355,8 @@ describe("schedule tool persistence", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
userProfile: { findUnique: async () => null },
|
userProfile: { findUnique: async () => null },
|
||||||
routine: {
|
routine: {
|
||||||
|
findFirst: async () => null,
|
||||||
|
count: async () => 0,
|
||||||
create: vi.fn(async () => ({
|
create: vi.fn(async () => ({
|
||||||
id: "routine-1",
|
id: "routine-1",
|
||||||
name: "Morning joke",
|
name: "Morning joke",
|
||||||
@ -354,6 +395,8 @@ describe("schedule tool persistence", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
userProfile: { findUnique: async () => null },
|
userProfile: { findUnique: async () => null },
|
||||||
routine: {
|
routine: {
|
||||||
|
findFirst: async () => null,
|
||||||
|
count: async () => 0,
|
||||||
create: vi.fn(async () => ({
|
create: vi.fn(async () => ({
|
||||||
id: "routine-1",
|
id: "routine-1",
|
||||||
name: "Morning joke",
|
name: "Morning joke",
|
||||||
@ -397,6 +440,8 @@ describe("schedule tool persistence", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
userProfile: { findUnique: async () => null },
|
userProfile: { findUnique: async () => null },
|
||||||
routine: {
|
routine: {
|
||||||
|
findFirst: async () => null,
|
||||||
|
count: async () => 0,
|
||||||
create: vi.fn(async () => ({
|
create: vi.fn(async () => ({
|
||||||
id: "routine-1",
|
id: "routine-1",
|
||||||
name: "Morning joke",
|
name: "Morning joke",
|
||||||
@ -631,7 +676,10 @@ describe("scheduling on behalf of another bot", () => {
|
|||||||
const append = vi.fn(async () => undefined);
|
const append = vi.fn(async () => undefined);
|
||||||
const deps = {
|
const deps = {
|
||||||
// No profile time zone: the routine falls back to UTC.
|
// No profile time zone: the routine falls back to UTC.
|
||||||
prisma: { routine: { create }, userProfile: { findUnique: async () => null } },
|
prisma: {
|
||||||
|
routine: { create, findFirst: async () => null, count: async () => 0 },
|
||||||
|
userProfile: { findUnique: async () => null },
|
||||||
|
},
|
||||||
events: { append },
|
events: { append },
|
||||||
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
||||||
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
} as unknown as Parameters<typeof createScheduleFromTool>[0];
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit";
|
import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit";
|
||||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
cronFromPreset,
|
cronFromPreset,
|
||||||
isOneShotRoutineCron,
|
isOneShotRoutineCron,
|
||||||
@ -8,6 +9,8 @@ import {
|
|||||||
ONCE_ROUTINE_CRON,
|
ONCE_ROUTINE_CRON,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
|
import { markRoutineNoticesRemoved, postRoutineNotice } from "@rakazo/db";
|
||||||
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { loadUserTimeZone } from "./current-time.js";
|
import { loadUserTimeZone } from "./current-time.js";
|
||||||
|
|
||||||
export { isOneShotRoutineCron, ONCE_ROUTINE_CRON };
|
export { isOneShotRoutineCron, ONCE_ROUTINE_CRON };
|
||||||
@ -276,7 +279,20 @@ function pickScheduleTarget<T extends { id: string; name: string; spaceId: strin
|
|||||||
return { error: `No bot named ${ref}.` };
|
return { error: `No bot named ${ref}.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist and enqueue a bot routine that wakes in the originating thread. */
|
/** More active routines than one bot can sensibly keep track of. */
|
||||||
|
export const MAX_ACTIVE_ROUTINES_PER_BOT = 50;
|
||||||
|
|
||||||
|
/** Where the bot that set a routine up writes its timeline line. */
|
||||||
|
export interface ScheduleNotice {
|
||||||
|
threadId: string;
|
||||||
|
botId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist and enqueue a bot routine that wakes in the originating thread. A repeating schedule
|
||||||
|
* with the name of one of the bot's active repeating routines changes that routine instead of
|
||||||
|
* adding a second one. With `notice`, the conversation gets a quiet line about it.
|
||||||
|
*/
|
||||||
export async function createScheduleFromTool(
|
export async function createScheduleFromTool(
|
||||||
deps: ScheduleToolDeps,
|
deps: ScheduleToolDeps,
|
||||||
input: ScheduleOwner & {
|
input: ScheduleOwner & {
|
||||||
@ -284,6 +300,7 @@ export async function createScheduleFromTool(
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
schedule: Record<string, unknown>;
|
schedule: Record<string, unknown>;
|
||||||
|
notice?: ScheduleNotice;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const name = input.name.trim();
|
const name = input.name.trim();
|
||||||
@ -298,6 +315,36 @@ export async function createScheduleFromTool(
|
|||||||
const resolved = resolveScheduleTiming(input.schedule, timezone);
|
const resolved = resolveScheduleTiming(input.schedule, timezone);
|
||||||
if (!resolved.ok) return { error: resolved.error };
|
if (!resolved.ok) return { error: resolved.error };
|
||||||
|
|
||||||
|
if (!resolved.oneShot) {
|
||||||
|
const existing = await deps.prisma.routine.findFirst({
|
||||||
|
where: {
|
||||||
|
botId: input.botId,
|
||||||
|
userId: input.userId,
|
||||||
|
active: true,
|
||||||
|
coachKey: null,
|
||||||
|
name: { equals: name, mode: "insensitive" },
|
||||||
|
NOT: { crons: { has: ONCE_ROUTINE_CRON } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return updateScheduleFromTool(deps, input, existing.id, {
|
||||||
|
prompt,
|
||||||
|
timezone,
|
||||||
|
cron: resolved.cron,
|
||||||
|
nextRunAt: resolved.nextRunAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = await deps.prisma.routine.count({
|
||||||
|
where: { botId: input.botId, active: true },
|
||||||
|
});
|
||||||
|
if (activeCount >= MAX_ACTIVE_ROUTINES_PER_BOT) {
|
||||||
|
return {
|
||||||
|
error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const row = await deps.prisma.routine.create({
|
const row = await deps.prisma.routine.create({
|
||||||
data: {
|
data: {
|
||||||
spaceId: input.spaceId,
|
spaceId: input.spaceId,
|
||||||
@ -341,6 +388,16 @@ export async function createScheduleFromTool(
|
|||||||
} catch {
|
} catch {
|
||||||
// Match routines.create: the reminder is live even if the thread signal fails.
|
// Match routines.create: the reminder is live even if the thread signal fails.
|
||||||
}
|
}
|
||||||
|
await postNotice(deps, input.notice, input.spaceId, {
|
||||||
|
kind: "event",
|
||||||
|
event: "routine.created",
|
||||||
|
routineId: row.id,
|
||||||
|
botId: input.botId,
|
||||||
|
name: row.name,
|
||||||
|
crons: row.crons,
|
||||||
|
timezone: row.timezone,
|
||||||
|
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
@ -353,6 +410,93 @@ export async function createScheduleFromTool(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateScheduleFromTool(
|
||||||
|
deps: ScheduleToolDeps,
|
||||||
|
input: ScheduleOwner & { notice?: ScheduleNotice },
|
||||||
|
routineId: string,
|
||||||
|
change: { prompt: string; timezone: string; cron: string; nextRunAt: Date },
|
||||||
|
) {
|
||||||
|
const row = await deps.prisma.routine.update({
|
||||||
|
where: { id: routineId },
|
||||||
|
data: {
|
||||||
|
prompt: change.prompt,
|
||||||
|
timezone: change.timezone,
|
||||||
|
crons: [change.cron],
|
||||||
|
nextRunAt: change.nextRunAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deps.jobs.enqueue(routineWakeupJob(row.id, change.nextRunAt));
|
||||||
|
} catch {
|
||||||
|
// The job reconciler re-enqueues active routines whose wakeup is missing.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deps.events.append({
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
threadId: row.threadId ?? input.threadId,
|
||||||
|
botId: row.botId,
|
||||||
|
type: "routine.updated",
|
||||||
|
payload: { name: row.name },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The routine is updated even if the thread signal fails.
|
||||||
|
}
|
||||||
|
await postNotice(deps, input.notice, input.spaceId, {
|
||||||
|
kind: "event",
|
||||||
|
event: "routine.updated",
|
||||||
|
routineId: row.id,
|
||||||
|
botId: row.botId,
|
||||||
|
name: row.name,
|
||||||
|
crons: row.crons,
|
||||||
|
timezone: row.timezone,
|
||||||
|
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
updated: true as const,
|
||||||
|
routineId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
cron: row.crons[0],
|
||||||
|
nextRunAt: row.nextRunAt?.toISOString() ?? null,
|
||||||
|
oneShot: false,
|
||||||
|
...(input.createdByBotId ? { bot: input.botName } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line is a courtesy: a routine that is live stays live when the line cannot be written. */
|
||||||
|
async function postNotice(
|
||||||
|
deps: ScheduleToolDeps,
|
||||||
|
notice: ScheduleNotice | undefined,
|
||||||
|
spaceId: string,
|
||||||
|
block: RoutineEventBlock,
|
||||||
|
) {
|
||||||
|
if (!notice) return;
|
||||||
|
try {
|
||||||
|
const posted = await postRoutineNotice(deps.prisma, {
|
||||||
|
spaceId,
|
||||||
|
threadId: notice.threadId,
|
||||||
|
botId: notice.botId,
|
||||||
|
block,
|
||||||
|
});
|
||||||
|
await deps.events.notify(notice.threadId, posted.seq);
|
||||||
|
} catch (error) {
|
||||||
|
getLogger().error("routine notice", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the timeline line of a removed routine; the removal itself has already happened. */
|
||||||
|
export async function markRemovedRoutineNotice(
|
||||||
|
deps: Pick<ScheduleToolDeps, "prisma" | "events">,
|
||||||
|
routine: { id: string; botId: string; noticeMessageId: string | null },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const marked = await markRoutineNoticesRemoved(deps.prisma, routine);
|
||||||
|
if (marked) await deps.events.notify(marked.threadId, marked.seq);
|
||||||
|
} catch (error) {
|
||||||
|
getLogger().error("routine notice removal", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routines a bot manages: its own (in a group only the ones of that thread) and the ones it
|
* Routines a bot manages: its own (in a group only the ones of that thread) and the ones it
|
||||||
* scheduled for other bots, wherever they wake.
|
* scheduled for other bots, wherever they wake.
|
||||||
@ -429,5 +573,6 @@ export async function cancelScheduleFromTool(
|
|||||||
|
|
||||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||||
await deps.jobs.cancel(routineJobKey(existing.id));
|
await deps.jobs.cancel(routineJobKey(existing.id));
|
||||||
|
await markRemovedRoutineNotice(deps, existing);
|
||||||
return { ok: true as const, routineId: existing.id, name: existing.name };
|
return { ok: true as const, routineId: existing.id, name: existing.name };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -260,6 +260,21 @@ export function inferScript(
|
|||||||
}
|
}
|
||||||
const coach = inferCoachScript(prompt);
|
const coach = inferCoachScript(prompt);
|
||||||
if (coach) return coach;
|
if (coach) return coach;
|
||||||
|
// Said in passing: the bot sets the routine up without asking first.
|
||||||
|
if (lower.includes("every sunday i plan the week")) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
assistant: "i will remind you every sunday at 6 pm.",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "schedule_create",
|
||||||
|
args: { name: "Weekly plan", prompt: "Plan the week with me.", cron: "0 18 * * 0" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
if (lower.includes("delegate to the bot named")) {
|
if (lower.includes("delegate to the bot named")) {
|
||||||
const name = namedBot(prompt) ?? "Researcher";
|
const name = namedBot(prompt) ?? "Researcher";
|
||||||
const brief =
|
const brief =
|
||||||
|
|||||||
@ -173,6 +173,24 @@ export const IntakeBlockSchema = z.discriminatedUnion("kind", [
|
|||||||
]);
|
]);
|
||||||
export type IntakeBlock = z.infer<typeof IntakeBlockSchema>;
|
export type IntakeBlock = z.infer<typeof IntakeBlockSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quiet line in the timeline for a routine the bot set up from the conversation. `removed` is
|
||||||
|
* set once the routine is gone, wherever it was removed.
|
||||||
|
*/
|
||||||
|
export const RoutineEventBlockSchema = z.object({
|
||||||
|
kind: z.literal("event"),
|
||||||
|
event: z.enum(["routine.created", "routine.updated"]),
|
||||||
|
routineId: Id,
|
||||||
|
/** The bot the routine belongs to; it may differ from the bot that wrote the line. */
|
||||||
|
botId: Id,
|
||||||
|
name: z.string(),
|
||||||
|
crons: z.array(z.string()),
|
||||||
|
timezone: z.string(),
|
||||||
|
nextRunAt: z.string().nullable(),
|
||||||
|
removed: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type RoutineEventBlock = z.infer<typeof RoutineEventBlockSchema>;
|
||||||
|
|
||||||
export const MessageBlock = z.discriminatedUnion("kind", [
|
export const MessageBlock = z.discriminatedUnion("kind", [
|
||||||
z.object({ kind: z.literal("text"), text: z.string() }),
|
z.object({ kind: z.literal("text"), text: z.string() }),
|
||||||
z.object({
|
z.object({
|
||||||
@ -182,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
AskBlockSchema,
|
AskBlockSchema,
|
||||||
ChoiceBlockSchema,
|
ChoiceBlockSchema,
|
||||||
IntakeNoteBlockSchema,
|
IntakeNoteBlockSchema,
|
||||||
|
RoutineEventBlockSchema,
|
||||||
z.object({
|
z.object({
|
||||||
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
||||||
description, and an Authorize button that flips to connected. */
|
description, and an Authorize button that flips to connected. */
|
||||||
@ -386,6 +405,6 @@ export function canReactToThreadMessage(message: Pick<ThreadMessage, "id" | "blo
|
|||||||
return (
|
return (
|
||||||
!message.id.startsWith("progress:") &&
|
!message.id.startsWith("progress:") &&
|
||||||
!message.id.startsWith("subagent:") &&
|
!message.id.startsWith("subagent:") &&
|
||||||
!message.blocks.some((block) => block.kind === "channel_message")
|
!message.blocks.some((block) => block.kind === "channel_message" || block.kind === "event")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
isAllowedAttachmentMimeType,
|
isAllowedAttachmentMimeType,
|
||||||
isAttachmentImageMimeType,
|
isAttachmentImageMimeType,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
|
import { routineEventHistoryText } from "./routine-events.js";
|
||||||
|
|
||||||
export class AttachmentValidationError extends Error {
|
export class AttachmentValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@ -137,6 +138,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
|||||||
if (block.kind === "task") {
|
if (block.kind === "task") {
|
||||||
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
|
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
|
||||||
}
|
}
|
||||||
|
if (block.kind === "event") return routineEventHistoryText(block);
|
||||||
if (block.kind === "handoff") {
|
if (block.kind === "handoff") {
|
||||||
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export * from "./model-providers.js";
|
|||||||
export * from "./oo-avatar.js";
|
export * from "./oo-avatar.js";
|
||||||
export * from "./quiet-hours.js";
|
export * from "./quiet-hours.js";
|
||||||
export * from "./response-bytes.js";
|
export * from "./response-bytes.js";
|
||||||
|
export * from "./routine-events.js";
|
||||||
export * from "./run-state.js";
|
export * from "./run-state.js";
|
||||||
export * from "./sandbox-command.js";
|
export * from "./sandbox-command.js";
|
||||||
export * from "./screen-lease.js";
|
export * from "./screen-lease.js";
|
||||||
|
|||||||
82
packages/core/src/routine-events.test.ts
Normal file
82
packages/core/src/routine-events.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { blocksToAgentHistoryText } from "./attachments.js";
|
||||||
|
import {
|
||||||
|
isRoutineEventMessage,
|
||||||
|
markRoutineEventsRemoved,
|
||||||
|
routineScheduleShape,
|
||||||
|
} from "./routine-events.js";
|
||||||
|
|
||||||
|
const line: RoutineEventBlock = {
|
||||||
|
kind: "event",
|
||||||
|
event: "routine.created",
|
||||||
|
routineId: "routine-1",
|
||||||
|
botId: "bot-1",
|
||||||
|
name: "Weekly plan",
|
||||||
|
crons: ["0 18 * * 1"],
|
||||||
|
timezone: "Europe/Berlin",
|
||||||
|
nextRunAt: "2026-10-05T16:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("routine lines", () => {
|
||||||
|
it("are messages of their own", () => {
|
||||||
|
expect(isRoutineEventMessage([line])).toBe(true);
|
||||||
|
expect(isRoutineEventMessage([line, { kind: "text", text: "Done." }])).toBe(false);
|
||||||
|
expect(isRoutineEventMessage([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are marked removed once and leave other routines alone", () => {
|
||||||
|
const other: MessageBlock = { ...line, routineId: "routine-2" };
|
||||||
|
const marked = markRoutineEventsRemoved([line, other], "routine-1");
|
||||||
|
expect(marked).toEqual([{ ...line, removed: true }, other]);
|
||||||
|
expect(markRoutineEventsRemoved(marked!, "routine-1")).toBeNull();
|
||||||
|
expect(markRoutineEventsRemoved([other], "routine-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tell a later turn what was set up, changed or removed", () => {
|
||||||
|
expect(blocksToAgentHistoryText([line])).toBe(
|
||||||
|
"[routine set up: Weekly plan (Every Monday at 6:00 PM, Europe/Berlin)]",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
blocksToAgentHistoryText([
|
||||||
|
{ ...line, event: "routine.updated", crons: ["@once"], name: "Call mum" },
|
||||||
|
]),
|
||||||
|
).toBe("[routine updated: Call mum (once at 2026-10-05T16:00:00.000Z)]");
|
||||||
|
expect(blocksToAgentHistoryText([{ ...line, removed: true }])).toBe(
|
||||||
|
"[routine removed: Weekly plan]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routineScheduleShape", () => {
|
||||||
|
it("reads the schedules people say out loud", () => {
|
||||||
|
expect(routineScheduleShape(["@once"])).toEqual({ kind: "once" });
|
||||||
|
expect(routineScheduleShape(["0 8 * * *"])).toEqual({ kind: "daily", hour: 8, minute: 0 });
|
||||||
|
expect(routineScheduleShape(["30 7 * * 1-5"])).toEqual({
|
||||||
|
kind: "weekdays",
|
||||||
|
hour: 7,
|
||||||
|
minute: 30,
|
||||||
|
});
|
||||||
|
expect(routineScheduleShape(["0 18 * * 7"])).toEqual({
|
||||||
|
kind: "weekly",
|
||||||
|
weekday: 0,
|
||||||
|
hour: 18,
|
||||||
|
minute: 0,
|
||||||
|
});
|
||||||
|
expect(routineScheduleShape(["0 9 1 * *"])).toEqual({
|
||||||
|
kind: "monthly",
|
||||||
|
day: 1,
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
});
|
||||||
|
expect(routineScheduleShape(["*/15 * * * *"])).toEqual({ kind: "minutes", every: 15 });
|
||||||
|
expect(routineScheduleShape(["0 */2 * * *"])).toEqual({ kind: "hours", every: 2 });
|
||||||
|
expect(routineScheduleShape(["0 * * * *"])).toEqual({ kind: "hourly" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves everything else to the next run", () => {
|
||||||
|
for (const crons of [["0 9 * * 1,3"], ["0 9 * 1 *"], ["0 9 * * *", "0 18 * * *"], ["nope"]]) {
|
||||||
|
expect(routineScheduleShape(crons)).toEqual({ kind: "other" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
72
packages/core/src/routine-events.ts
Normal file
72
packages/core/src/routine-events.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { formatCron, isOneShotRoutineCrons } from "./cron.js";
|
||||||
|
|
||||||
|
/** A message that is only timeline lines, shown without a speaker, bubble or reactions. */
|
||||||
|
export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean {
|
||||||
|
return blocks.length > 0 && blocks.every((block) => block.kind === "event");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocks with every line about `routineId` marked removed, or null when nothing changes. */
|
||||||
|
export function markRoutineEventsRemoved(
|
||||||
|
blocks: readonly MessageBlock[],
|
||||||
|
routineId: string,
|
||||||
|
): MessageBlock[] | null {
|
||||||
|
let changed = false;
|
||||||
|
const next = blocks.map((block) => {
|
||||||
|
if (block.kind !== "event" || block.routineId !== routineId || block.removed) return block;
|
||||||
|
changed = true;
|
||||||
|
return { ...block, removed: true };
|
||||||
|
});
|
||||||
|
return changed ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a later turn reads the line, so the bot knows what it set up and what is gone. */
|
||||||
|
export function routineEventHistoryText(block: RoutineEventBlock): string {
|
||||||
|
if (block.removed) return `[routine removed: ${block.name}]`;
|
||||||
|
const verb = block.event === "routine.updated" ? "updated" : "set up";
|
||||||
|
const schedule = isOneShotRoutineCrons(block.crons)
|
||||||
|
? `once at ${block.nextRunAt ?? "an unknown time"}`
|
||||||
|
: `${block.crons.map(formatCron).join(", ")}, ${block.timezone}`;
|
||||||
|
return `[routine ${verb}: ${block.name} (${schedule})]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of a routine schedule a one-line summary needs; clients word it in their locale. */
|
||||||
|
export type RoutineScheduleShape =
|
||||||
|
| { kind: "once" }
|
||||||
|
| { kind: "hourly" }
|
||||||
|
| { kind: "minutes" | "hours"; every: number }
|
||||||
|
| { kind: "daily" | "weekdays"; hour: number; minute: number }
|
||||||
|
| { kind: "weekly"; weekday: number; hour: number; minute: number }
|
||||||
|
| { kind: "monthly"; day: number; hour: number; minute: number }
|
||||||
|
| { kind: "other" };
|
||||||
|
|
||||||
|
const CLOCK_FIELD = /^\d{1,2}$/;
|
||||||
|
|
||||||
|
export function routineScheduleShape(crons: readonly string[]): RoutineScheduleShape {
|
||||||
|
if (isOneShotRoutineCrons([...crons])) return { kind: "once" };
|
||||||
|
if (crons.length !== 1) return { kind: "other" };
|
||||||
|
const parts = crons[0]!.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return { kind: "other" };
|
||||||
|
const [minute, hour, day, month, weekday] = parts as [string, string, string, string, string];
|
||||||
|
if (month !== "*") return { kind: "other" };
|
||||||
|
const step = (field: string) => /^\*\/(\d+)$/.exec(field)?.[1];
|
||||||
|
if (day === "*" && weekday === "*") {
|
||||||
|
if (minute === "0" && hour === "*") return { kind: "hourly" };
|
||||||
|
const minuteStep = step(minute);
|
||||||
|
if (minuteStep && hour === "*") return { kind: "minutes", every: Number(minuteStep) };
|
||||||
|
const hourStep = step(hour);
|
||||||
|
if (minute === "0" && hourStep) return { kind: "hours", every: Number(hourStep) };
|
||||||
|
}
|
||||||
|
if (!CLOCK_FIELD.test(minute) || !CLOCK_FIELD.test(hour)) return { kind: "other" };
|
||||||
|
const clock = { hour: Number(hour), minute: Number(minute) };
|
||||||
|
if (clock.hour > 23 || clock.minute > 59) return { kind: "other" };
|
||||||
|
if (day === "*" && weekday === "*") return { kind: "daily", ...clock };
|
||||||
|
if (day === "*" && weekday === "1-5") return { kind: "weekdays", ...clock };
|
||||||
|
if (day === "*" && /^[0-7]$/.test(weekday)) {
|
||||||
|
return { kind: "weekly", weekday: Number(weekday) % 7, ...clock };
|
||||||
|
}
|
||||||
|
if (weekday === "*" && /^([1-9]|[12]\d|3[01])$/.test(day)) {
|
||||||
|
return { kind: "monthly", day: Number(day), ...clock };
|
||||||
|
}
|
||||||
|
return { kind: "other" };
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- A routine the bot set up from the conversation remembers its timeline line, so removing the
|
||||||
|
-- routine anywhere can mark that line removed.
|
||||||
|
ALTER TABLE "routines" ADD COLUMN "noticeMessageId" TEXT;
|
||||||
@ -779,6 +779,8 @@ model Routine {
|
|||||||
messageProvider String?
|
messageProvider String?
|
||||||
/// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise.
|
/// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise.
|
||||||
coachKey String?
|
coachKey String?
|
||||||
|
/// The latest timeline line the bot wrote about this routine; marked removed with it.
|
||||||
|
noticeMessageId String?
|
||||||
lastRunAt DateTime?
|
lastRunAt DateTime?
|
||||||
nextRunAt DateTime?
|
nextRunAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export * from "./project-goals.js";
|
|||||||
export * from "./project-workspace.js";
|
export * from "./project-workspace.js";
|
||||||
export * from "./push-subscriptions.js";
|
export * from "./push-subscriptions.js";
|
||||||
export * from "./repos.js";
|
export * from "./repos.js";
|
||||||
|
export * from "./routine-notices.js";
|
||||||
export * from "./scope.js";
|
export * from "./scope.js";
|
||||||
export * from "./spaces.js";
|
export * from "./spaces.js";
|
||||||
export * from "./studio-agent.js";
|
export * from "./studio-agent.js";
|
||||||
|
|||||||
84
packages/db/src/routine-notices.ts
Normal file
84
packages/db/src/routine-notices.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { RoutineEventBlock } from "@rakazo/contracts";
|
||||||
|
import { markRoutineEventsRemoved } from "@rakazo/core";
|
||||||
|
import type { Prisma, PrismaClient } from "./client.js";
|
||||||
|
import { appendEventInTransaction } from "./events.js";
|
||||||
|
import { createThreadMessageInTransaction } from "./messages.js";
|
||||||
|
|
||||||
|
export interface RoutineNoticeInput {
|
||||||
|
spaceId: string;
|
||||||
|
threadId: string;
|
||||||
|
/** The bot that set the routine up and writes the line. */
|
||||||
|
botId: string;
|
||||||
|
block: RoutineEventBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the timeline line for a routine the bot set up and remember it on the routine. */
|
||||||
|
export async function postRoutineNotice(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: RoutineNoticeInput,
|
||||||
|
): Promise<{ messageId: string; seq: number }> {
|
||||||
|
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
|
threadId: input.threadId,
|
||||||
|
role: "bot",
|
||||||
|
blocks: [input.block],
|
||||||
|
botId: input.botId,
|
||||||
|
markUnread: false,
|
||||||
|
});
|
||||||
|
await tx.routine.updateMany({
|
||||||
|
where: { id: input.block.routineId },
|
||||||
|
data: { noticeMessageId: message.id },
|
||||||
|
});
|
||||||
|
const event = await appendEventInTransaction(tx, {
|
||||||
|
spaceId: input.spaceId,
|
||||||
|
threadId: input.threadId,
|
||||||
|
botId: input.botId,
|
||||||
|
type: "thread.message.created",
|
||||||
|
payload: { messageId: message.id, role: "bot", blocks: [input.block] },
|
||||||
|
});
|
||||||
|
return { messageId: message.id, seq: event.seq };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark every line about a removed routine in the thread of its latest line. Returns the thread and
|
||||||
|
* event seq to notify, or null when the routine had no line or nothing changed.
|
||||||
|
*/
|
||||||
|
export async function markRoutineNoticesRemoved(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
routine: { id: string; botId: string; noticeMessageId: string | null },
|
||||||
|
): Promise<{ threadId: string; seq: number } | null> {
|
||||||
|
if (!routine.noticeMessageId) return null;
|
||||||
|
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
|
const notice = await tx.message.findUnique({
|
||||||
|
where: { id: routine.noticeMessageId! },
|
||||||
|
select: { threadId: true, thread: { select: { spaceId: true } } },
|
||||||
|
});
|
||||||
|
if (!notice) return null;
|
||||||
|
const messages = await tx.message.findMany({
|
||||||
|
where: {
|
||||||
|
threadId: notice.threadId,
|
||||||
|
blocks: { array_contains: [{ kind: "event", routineId: routine.id }] },
|
||||||
|
},
|
||||||
|
select: { id: true, role: true, botId: true, blocks: true },
|
||||||
|
});
|
||||||
|
let seq: number | null = null;
|
||||||
|
for (const message of messages) {
|
||||||
|
const blocks = markRoutineEventsRemoved(message.blocks as RoutineEventBlock[], routine.id);
|
||||||
|
if (!blocks) continue;
|
||||||
|
await tx.message.update({
|
||||||
|
where: { id: message.id },
|
||||||
|
data: { blocks: blocks as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
const event = await appendEventInTransaction(tx, {
|
||||||
|
spaceId: notice.thread.spaceId,
|
||||||
|
threadId: notice.threadId,
|
||||||
|
botId: message.botId ?? routine.botId,
|
||||||
|
type: "thread.message.updated",
|
||||||
|
payload: { messageId: message.id, role: message.role, blocks },
|
||||||
|
});
|
||||||
|
seq = event.seq;
|
||||||
|
}
|
||||||
|
return seq === null ? null : { threadId: notice.threadId, seq };
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user