flyspace-cli 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1997 -1906
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1219,6 +1219,7 @@ import {
|
|
|
1219
1219
|
readdirSync as readdirSync2,
|
|
1220
1220
|
readFileSync as readFileSync7,
|
|
1221
1221
|
readSync,
|
|
1222
|
+
realpathSync as realpathSync2,
|
|
1222
1223
|
statSync as statSync2,
|
|
1223
1224
|
symlinkSync
|
|
1224
1225
|
} from "fs";
|
|
@@ -1362,7 +1363,23 @@ function detectRunners(history = { claude: false, codex: false }) {
|
|
|
1362
1363
|
}
|
|
1363
1364
|
];
|
|
1364
1365
|
}
|
|
1365
|
-
function
|
|
1366
|
+
function linkedTargets(slug) {
|
|
1367
|
+
const out = /* @__PURE__ */ new Map();
|
|
1368
|
+
if (!SLUG_RE.test(slug)) return out;
|
|
1369
|
+
const dir = join7(flyspaceRoot(), slug);
|
|
1370
|
+
try {
|
|
1371
|
+
for (const e of readdirSync2(dir, { withFileTypes: true })) {
|
|
1372
|
+
if (!e.isSymbolicLink()) continue;
|
|
1373
|
+
try {
|
|
1374
|
+
out.set(realpathSync2(join7(dir, e.name)), e.name);
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
return out;
|
|
1381
|
+
}
|
|
1382
|
+
function scanOnboarding(slug) {
|
|
1366
1383
|
const byPath = /* @__PURE__ */ new Map();
|
|
1367
1384
|
const history = { claude: false, codex: false };
|
|
1368
1385
|
const add = (rawPath, source, mtime, sessions2, trusted = false) => {
|
|
@@ -1432,6 +1449,18 @@ function scanOnboarding() {
|
|
|
1432
1449
|
projects.sort(
|
|
1433
1450
|
(a, b) => Number(b.sources.length > 1) - Number(a.sources.length > 1) || Number(b.trusted) - Number(a.trusted) || b.lastUsed - a.lastUsed || b.sessions - a.sessions
|
|
1434
1451
|
);
|
|
1452
|
+
if (slug) {
|
|
1453
|
+
const linked = linkedTargets(slug);
|
|
1454
|
+
for (const p of projects) {
|
|
1455
|
+
let real = p.path;
|
|
1456
|
+
try {
|
|
1457
|
+
real = realpathSync2(p.path);
|
|
1458
|
+
} catch {
|
|
1459
|
+
}
|
|
1460
|
+
const name = linked.get(real);
|
|
1461
|
+
if (name) p.linkedAs = name;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1435
1464
|
return {
|
|
1436
1465
|
root: flyspaceRoot(),
|
|
1437
1466
|
runners: detectRunners(history),
|
|
@@ -1443,13 +1472,23 @@ var GENERAL_TEMPLATE_DIR = join7(paths.PACKAGE_ROOT, "templates", "general");
|
|
|
1443
1472
|
function makeLink(target, linkPath) {
|
|
1444
1473
|
symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
|
|
1445
1474
|
}
|
|
1475
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
1476
|
+
function assertLinkable(target) {
|
|
1477
|
+
const home2 = homedir3();
|
|
1478
|
+
const root = existsSync6(flyspaceRoot()) ? realpathSync2(flyspaceRoot()) : flyspaceRoot();
|
|
1479
|
+
const denied = [join7(home2, ".ssh"), join7(home2, "Library"), join7(home2, ".config"), join7(home2, ".gnupg")];
|
|
1480
|
+
if (target === home2 || target === "/" || target === root || target.startsWith(`${root}/`)) {
|
|
1481
|
+
throw new Error(`cannot link ${target}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (denied.some((d) => target === d || target.startsWith(`${d}/`))) throw new Error(`cannot link ${target}`);
|
|
1484
|
+
}
|
|
1446
1485
|
function linkProjectFolders(slug, items) {
|
|
1447
|
-
if (
|
|
1486
|
+
if (!SLUG_RE.test(slug)) throw new Error(`invalid workspace slug: ${slug}`);
|
|
1448
1487
|
const dir = join7(flyspaceRoot(), slug);
|
|
1449
1488
|
mkdirSync5(dir, { recursive: true });
|
|
1450
1489
|
const results = [];
|
|
1451
1490
|
for (const item of items) {
|
|
1452
|
-
if (
|
|
1491
|
+
if (!SLUG_RE.test(item.name)) throw new Error(`invalid channel name: ${item.name}`);
|
|
1453
1492
|
const dest = join7(dir, item.name);
|
|
1454
1493
|
let exists = true;
|
|
1455
1494
|
try {
|
|
@@ -1460,10 +1499,21 @@ function linkProjectFolders(slug, items) {
|
|
|
1460
1499
|
if (exists && item.name === "general" && !item.path) {
|
|
1461
1500
|
cpSync(GENERAL_TEMPLATE_DIR, dest, { recursive: true, force: false });
|
|
1462
1501
|
}
|
|
1502
|
+
if (exists && item.path) {
|
|
1503
|
+
let current = null;
|
|
1504
|
+
try {
|
|
1505
|
+
current = realpathSync2(dest);
|
|
1506
|
+
} catch {
|
|
1507
|
+
}
|
|
1508
|
+
if (current !== realpathSync2(resolve3(item.path))) {
|
|
1509
|
+
throw new Error(`#${item.name} already points at another folder`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1463
1512
|
if (!exists) {
|
|
1464
1513
|
if (item.path) {
|
|
1465
1514
|
const target = resolve3(item.path);
|
|
1466
1515
|
if (!statSync2(target).isDirectory()) throw new Error(`not a directory: ${target}`);
|
|
1516
|
+
assertLinkable(realpathSync2(target));
|
|
1467
1517
|
makeLink(target, dest);
|
|
1468
1518
|
} else {
|
|
1469
1519
|
mkdirSync5(dest, { recursive: true });
|
|
@@ -1785,7 +1835,7 @@ async function runAuthCmd(args) {
|
|
|
1785
1835
|
}
|
|
1786
1836
|
|
|
1787
1837
|
// ../api/src/version.ts
|
|
1788
|
-
var CLI_VERSION = true ? "0.1.
|
|
1838
|
+
var CLI_VERSION = true ? "0.1.29" : null;
|
|
1789
1839
|
function isOlderVersion(a, b) {
|
|
1790
1840
|
const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map(Number);
|
|
1791
1841
|
const [pa, pb] = [parse(a), parse(b)];
|
|
@@ -2331,7 +2381,7 @@ async function runProfiles(args = []) {
|
|
|
2331
2381
|
|
|
2332
2382
|
// src/commands/upgrade.ts
|
|
2333
2383
|
import { execSync as execSync6 } from "child_process";
|
|
2334
|
-
import { realpathSync as
|
|
2384
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
2335
2385
|
import { join as join11 } from "path";
|
|
2336
2386
|
function restartInstalledProfiles() {
|
|
2337
2387
|
if (isHostServiceInstalled()) {
|
|
@@ -2355,7 +2405,7 @@ Restarting ${installed.length} instance(s)...`);
|
|
|
2355
2405
|
function npmInstallCommand() {
|
|
2356
2406
|
let prefix;
|
|
2357
2407
|
try {
|
|
2358
|
-
const entry =
|
|
2408
|
+
const entry = realpathSync3(process.argv[1] ?? "");
|
|
2359
2409
|
const at = entry.indexOf(`/lib/node_modules/${"flyspace-cli"}/`);
|
|
2360
2410
|
if (at > 0) prefix = entry.slice(0, at);
|
|
2361
2411
|
} catch {
|
|
@@ -2368,7 +2418,7 @@ function npmEnv() {
|
|
|
2368
2418
|
return home2 ? { ...process.env, PATH: `${join11(home2, "node", "bin")}:${process.env.PATH ?? ""}` } : process.env;
|
|
2369
2419
|
}
|
|
2370
2420
|
async function runUpgrade() {
|
|
2371
|
-
const current = "0.1.
|
|
2421
|
+
const current = "0.1.29";
|
|
2372
2422
|
console.log(`Current version: ${current}`);
|
|
2373
2423
|
console.log("Checking for updates...\n");
|
|
2374
2424
|
try {
|
|
@@ -8514,7 +8564,7 @@ function createGuestRouter() {
|
|
|
8514
8564
|
}
|
|
8515
8565
|
|
|
8516
8566
|
// ../api/src/dashboard/auth-routes.ts
|
|
8517
|
-
import { Hono as
|
|
8567
|
+
import { Hono as Hono8 } from "hono";
|
|
8518
8568
|
|
|
8519
8569
|
// ../api/src/dashboard/supabase-server-client.ts
|
|
8520
8570
|
import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
|
|
@@ -8624,6 +8674,8 @@ function pruneExpired(now) {
|
|
|
8624
8674
|
if (entry.expires <= now) resolved.delete(key);
|
|
8625
8675
|
}
|
|
8626
8676
|
}
|
|
8677
|
+
var SUPPORT_NAME = "Flyspace Support";
|
|
8678
|
+
var SUPPORT_AVATAR_URL = "/logo/flyspace-mark.svg";
|
|
8627
8679
|
async function resolveAuthContext(c, opts = {}) {
|
|
8628
8680
|
const cookie = c.req?.header("Cookie") ?? "";
|
|
8629
8681
|
if (!cookie) return resolveAuthContextUncached(c);
|
|
@@ -8682,8 +8734,9 @@ async function resolveAuthContextUncached(c) {
|
|
|
8682
8734
|
handle: membership.handle,
|
|
8683
8735
|
role: membership.role,
|
|
8684
8736
|
workspaceId: workspace.id,
|
|
8685
|
-
|
|
8686
|
-
|
|
8737
|
+
// A support admin posts into a customer's workspace as Flyspace, not as
|
|
8738
|
+
// the staff member's personal profile.
|
|
8739
|
+
...membership.isSupport ? { name: SUPPORT_NAME, isSupport: true } : { name: metadata.name, avatarKey: metadata.avatarKey },
|
|
8687
8740
|
onboarded: Boolean(metadata.onboardedAt),
|
|
8688
8741
|
passwordSet: Boolean(metadata.passwordSetAt),
|
|
8689
8742
|
agentConnectedAt: workspace.agentConnectedAt ?? null,
|
|
@@ -8752,1781 +8805,2031 @@ async function verifyTurnstileToken(token) {
|
|
|
8752
8805
|
return data.action === void 0 || data.action === "login";
|
|
8753
8806
|
}
|
|
8754
8807
|
|
|
8755
|
-
// ../api/src/
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
const
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8808
|
+
// ../api/src/store/device-store.ts
|
|
8809
|
+
import { createHash as createHash7, createPublicKey as createPublicKey2, randomBytes as randomBytes4, randomInt } from "crypto";
|
|
8810
|
+
var DEVICE_CODE_TTL_MS = 10 * 6e4;
|
|
8811
|
+
var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
|
|
8812
|
+
var CHALLENGE_TTL_MS = 2 * 6e4;
|
|
8813
|
+
var POLL_INTERVAL_S = 5;
|
|
8814
|
+
var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
|
|
8815
|
+
function parseEd25519PublicKey(b64) {
|
|
8816
|
+
if (typeof b64 !== "string" || b64.length > 200) return null;
|
|
8817
|
+
try {
|
|
8818
|
+
const key = createPublicKey2({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
8819
|
+
return key.asymmetricKeyType === "ed25519" ? key : null;
|
|
8820
|
+
} catch {
|
|
8821
|
+
return null;
|
|
8822
|
+
}
|
|
8823
|
+
}
|
|
8824
|
+
var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
|
|
8825
|
+
function generateUserCode() {
|
|
8826
|
+
const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
8827
|
+
return `${pick()}-${pick()}`;
|
|
8828
|
+
}
|
|
8829
|
+
function normalizeUserCode(input2) {
|
|
8830
|
+
const raw = input2.toUpperCase().replace(/[\s-]/g, "");
|
|
8831
|
+
if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
|
|
8832
|
+
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
|
8833
|
+
}
|
|
8834
|
+
function toDevice(r) {
|
|
8835
|
+
return {
|
|
8836
|
+
id: String(r["id"]),
|
|
8837
|
+
userId: String(r["user_id"]),
|
|
8838
|
+
name: String(r["name"]),
|
|
8839
|
+
platform: r["platform"] ?? null,
|
|
8840
|
+
publicKey: String(r["public_key"]),
|
|
8841
|
+
createdAt: String(r["created_at"]),
|
|
8842
|
+
lastSeenAt: r["last_seen_at"] ?? null,
|
|
8843
|
+
revokedAt: r["revoked_at"] ?? null
|
|
8777
8844
|
};
|
|
8778
|
-
auth.post("/login", emailedLink(deliverSignInLink));
|
|
8779
|
-
auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
|
|
8780
|
-
auth.get("/session", async (c) => {
|
|
8781
|
-
const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
|
|
8782
|
-
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
8783
|
-
return c.json({
|
|
8784
|
-
email: ctx.email,
|
|
8785
|
-
handle: ctx.handle,
|
|
8786
|
-
role: ctx.role,
|
|
8787
|
-
name: ctx.name,
|
|
8788
|
-
avatarUrl: ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
|
|
8789
|
-
onboarded: ctx.onboarded,
|
|
8790
|
-
passwordSet: ctx.passwordSet,
|
|
8791
|
-
agentConnectedAt: ctx.agentConnectedAt,
|
|
8792
|
-
plan: ctx.plan ?? null,
|
|
8793
|
-
onboarding: { pending: onboardingPending(ctx) }
|
|
8794
|
-
});
|
|
8795
|
-
});
|
|
8796
|
-
auth.patch("/handle", async (c) => {
|
|
8797
|
-
const ctx = await resolveAuthContext(c);
|
|
8798
|
-
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
8799
|
-
if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
|
|
8800
|
-
const body = await c.req.json().catch(() => ({}));
|
|
8801
|
-
const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
|
|
8802
|
-
if (!HANDLE_PATTERN.test(handle)) {
|
|
8803
|
-
return c.json(
|
|
8804
|
-
{ error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
|
|
8805
|
-
400
|
|
8806
|
-
);
|
|
8807
|
-
}
|
|
8808
|
-
try {
|
|
8809
|
-
await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
|
|
8810
|
-
} catch (err) {
|
|
8811
|
-
if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
|
|
8812
|
-
throw err;
|
|
8813
|
-
}
|
|
8814
|
-
return c.json({ handle });
|
|
8815
|
-
});
|
|
8816
|
-
auth.post("/logout", async (c) => {
|
|
8817
|
-
const supabase = createRequestSupabaseClient(c);
|
|
8818
|
-
await supabase.auth.signOut();
|
|
8819
|
-
return c.json({ ok: true });
|
|
8820
|
-
});
|
|
8821
|
-
return auth;
|
|
8822
8845
|
}
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8846
|
+
function toPending(r) {
|
|
8847
|
+
return {
|
|
8848
|
+
id: String(r["id"]),
|
|
8849
|
+
userCode: String(r["user_code"]),
|
|
8850
|
+
publicKey: String(r["public_key"]),
|
|
8851
|
+
name: String(r["name"]),
|
|
8852
|
+
platform: r["platform"] ?? null,
|
|
8853
|
+
requesterIp: r["requester_ip"] ?? null,
|
|
8854
|
+
approvedBy: r["approved_by"] ?? null,
|
|
8855
|
+
workspaceIds: r["workspace_ids"] ?? [],
|
|
8856
|
+
deniedAt: r["denied_at"] ?? null,
|
|
8857
|
+
lastPolledAt: r["last_polled_at"] ?? null,
|
|
8858
|
+
expiresAt: String(r["expires_at"]),
|
|
8859
|
+
createdAt: String(r["created_at"])
|
|
8860
|
+
};
|
|
8836
8861
|
}
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
if (c.req.method === "OPTIONS") return next();
|
|
8840
|
-
if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
|
|
8841
|
-
const auth = c.get("auth");
|
|
8842
|
-
if (!auth) return next();
|
|
8843
|
-
if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
|
|
8844
|
-
return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
|
|
8845
|
-
}
|
|
8846
|
-
return next();
|
|
8862
|
+
function fail(what, error2) {
|
|
8863
|
+
throw new Error(`${what}: ${error2.message}`);
|
|
8847
8864
|
}
|
|
8848
|
-
function
|
|
8849
|
-
const
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
} catch (err) {
|
|
8863
|
-
console.warn("[billing] subscription lookup failed:", err);
|
|
8864
|
-
}
|
|
8865
|
-
}
|
|
8866
|
-
return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
|
|
8867
|
-
});
|
|
8868
|
-
billing.post("/checkout", async (c) => {
|
|
8869
|
-
const auth = c.get("auth");
|
|
8870
|
-
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
8871
|
-
const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
8872
|
-
if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
|
|
8873
|
-
let plan = null;
|
|
8874
|
-
if (stripeSubscriptionId) {
|
|
8875
|
-
try {
|
|
8876
|
-
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
8877
|
-
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
8878
|
-
} catch (err) {
|
|
8879
|
-
console.warn("[billing] subscription lookup failed:", err);
|
|
8880
|
-
}
|
|
8881
|
-
}
|
|
8882
|
-
const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
|
|
8883
|
-
const session = await getStripeClient().checkout.sessions.create({
|
|
8884
|
-
mode: "setup",
|
|
8885
|
-
currency: "usd",
|
|
8886
|
-
customer: stripeCustomerId,
|
|
8887
|
-
success_url: `${origin}/?billing=done`,
|
|
8888
|
-
cancel_url: `${origin}/`,
|
|
8889
|
-
metadata: { workspace_id: auth.workspaceId },
|
|
8890
|
-
...plan && {
|
|
8891
|
-
custom_text: {
|
|
8892
|
-
submit: {
|
|
8893
|
-
message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
|
|
8894
|
-
}
|
|
8895
|
-
}
|
|
8896
|
-
}
|
|
8865
|
+
async function createDeviceCode(input2) {
|
|
8866
|
+
const db = getSupabaseClient();
|
|
8867
|
+
await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8868
|
+
const deviceCode = randomBytes4(32).toString("hex");
|
|
8869
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8870
|
+
const userCode = generateUserCode();
|
|
8871
|
+
const { error: error2 } = await db.from("device_codes").insert({
|
|
8872
|
+
device_code_hash: sha256(deviceCode),
|
|
8873
|
+
user_code: userCode,
|
|
8874
|
+
public_key: input2.publicKey,
|
|
8875
|
+
name: input2.name,
|
|
8876
|
+
platform: input2.platform,
|
|
8877
|
+
requester_ip: input2.requesterIp,
|
|
8878
|
+
expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
|
|
8897
8879
|
});
|
|
8898
|
-
if (!
|
|
8899
|
-
|
|
8900
|
-
}
|
|
8901
|
-
|
|
8880
|
+
if (!error2) return { deviceCode, userCode };
|
|
8881
|
+
if (error2.code !== "23505") fail("createDeviceCode", error2);
|
|
8882
|
+
}
|
|
8883
|
+
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
8902
8884
|
}
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
// ../api/src/dashboard/admin-middleware.ts
|
|
8908
|
-
function platformAdminEmails() {
|
|
8909
|
-
const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
|
|
8910
|
-
return new Set(
|
|
8911
|
-
raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
|
|
8912
|
-
);
|
|
8885
|
+
async function getDeviceCodeBySecret(deviceCode) {
|
|
8886
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
|
|
8887
|
+
if (error2) fail("getDeviceCodeBySecret", error2);
|
|
8888
|
+
return data ? toPending(data) : null;
|
|
8913
8889
|
}
|
|
8914
|
-
|
|
8915
|
-
const
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
const
|
|
8921
|
-
if (
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
function
|
|
8931
|
-
const
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
})
|
|
8890
|
+
async function getDeviceCodeByUserCode(userCode) {
|
|
8891
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
|
|
8892
|
+
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
8893
|
+
return data ? toPending(data) : null;
|
|
8894
|
+
}
|
|
8895
|
+
async function markPolled(id) {
|
|
8896
|
+
const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
|
|
8897
|
+
if (error2) fail("markPolled", error2);
|
|
8898
|
+
}
|
|
8899
|
+
async function decideDeviceCode(id, userId, decision) {
|
|
8900
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
|
|
8901
|
+
decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
|
|
8902
|
+
).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
|
|
8903
|
+
if (error2) fail("decideDeviceCode", error2);
|
|
8904
|
+
return (data ?? []).length > 0;
|
|
8905
|
+
}
|
|
8906
|
+
async function redeemDeviceCode(pending2) {
|
|
8907
|
+
const db = getSupabaseClient();
|
|
8908
|
+
const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
|
|
8909
|
+
if (claimError) fail("redeemDeviceCode", claimError);
|
|
8910
|
+
if (!claimed?.length) return null;
|
|
8911
|
+
const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
|
|
8912
|
+
const { data, error: error2 } = existing ? await db.from("devices").update({ name: pending2.name, platform: pending2.platform }).eq("id", existing.id).select("*").single() : await db.from("devices").insert({
|
|
8913
|
+
user_id: pending2.approvedBy,
|
|
8914
|
+
name: pending2.name,
|
|
8915
|
+
platform: pending2.platform,
|
|
8916
|
+
public_key: pending2.publicKey
|
|
8917
|
+
}).select("*").single();
|
|
8918
|
+
if (error2) fail("redeemDeviceCode", error2);
|
|
8919
|
+
const device = toDevice(data);
|
|
8920
|
+
if (pending2.workspaceIds.length) {
|
|
8921
|
+
const { error: bindError } = await db.from("device_workspaces").upsert(
|
|
8922
|
+
pending2.workspaceIds.map((workspaceId) => ({
|
|
8923
|
+
device_id: device.id,
|
|
8924
|
+
workspace_id: workspaceId,
|
|
8925
|
+
bound_by: pending2.approvedBy
|
|
8926
|
+
})),
|
|
8927
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8951
8928
|
);
|
|
8952
|
-
|
|
8953
|
-
}
|
|
8954
|
-
|
|
8955
|
-
const invites = await listSetupInvites();
|
|
8956
|
-
return c.json({ invites });
|
|
8957
|
-
});
|
|
8958
|
-
admin.post("/setup-invites", async (c) => {
|
|
8959
|
-
const body = await c.req.json().catch(() => ({}));
|
|
8960
|
-
const ownerEmail = body.ownerEmail?.trim().toLowerCase();
|
|
8961
|
-
if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
|
|
8962
|
-
return c.json({ error: "ownerEmail must be a valid email address" }, 400);
|
|
8963
|
-
}
|
|
8964
|
-
const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
|
|
8965
|
-
if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
|
|
8966
|
-
const ownerName = body.ownerName?.trim().slice(0, 80) || null;
|
|
8967
|
-
const message = body.message?.trim() || null;
|
|
8968
|
-
if (message && message.length > INVITE_MESSAGE_MAX) {
|
|
8969
|
-
return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
|
|
8970
|
-
}
|
|
8971
|
-
const operator = c.get("platformOperator");
|
|
8972
|
-
const token = await issueSetupInvite({
|
|
8973
|
-
email: ownerEmail,
|
|
8974
|
-
ownerName,
|
|
8975
|
-
plan,
|
|
8976
|
-
message,
|
|
8977
|
-
invitedBy: operator.userId
|
|
8978
|
-
});
|
|
8979
|
-
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
8980
|
-
inviteUrl.searchParams.set("token", token);
|
|
8981
|
-
if (body.sendEmail === false) {
|
|
8982
|
-
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
8983
|
-
}
|
|
8984
|
-
try {
|
|
8985
|
-
await deliverWorkspaceSetupInvite({
|
|
8986
|
-
email: ownerEmail,
|
|
8987
|
-
inviteLink: inviteUrl.toString(),
|
|
8988
|
-
message
|
|
8989
|
-
});
|
|
8990
|
-
} catch (err) {
|
|
8991
|
-
await deleteSetupInvite(token).catch(() => {
|
|
8992
|
-
});
|
|
8993
|
-
return c.json(
|
|
8994
|
-
{
|
|
8995
|
-
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
8996
|
-
},
|
|
8997
|
-
500
|
|
8998
|
-
);
|
|
8999
|
-
}
|
|
9000
|
-
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9001
|
-
});
|
|
9002
|
-
admin.delete("/setup-invites/:id", async (c) => {
|
|
9003
|
-
await revokeSetupInvite(c.req.param("id"));
|
|
9004
|
-
return c.json({ revoked: true });
|
|
9005
|
-
});
|
|
9006
|
-
admin.post("/setup-invites/:id/resend", async (c) => {
|
|
9007
|
-
const operator = c.get("platformOperator");
|
|
9008
|
-
let token;
|
|
9009
|
-
let invite;
|
|
9010
|
-
try {
|
|
9011
|
-
({ token, invite } = await reissueSetupInvite(c.req.param("id")));
|
|
9012
|
-
} catch (err) {
|
|
9013
|
-
return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
|
|
9014
|
-
}
|
|
9015
|
-
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9016
|
-
inviteUrl.searchParams.set("token", token);
|
|
9017
|
-
if (c.req.query("email") === "false") {
|
|
9018
|
-
return c.json({ sent: false, inviteLink: inviteUrl.toString() });
|
|
9019
|
-
}
|
|
9020
|
-
try {
|
|
9021
|
-
await deliverWorkspaceSetupInvite({
|
|
9022
|
-
email: invite.email,
|
|
9023
|
-
inviteLink: inviteUrl.toString(),
|
|
9024
|
-
message: invite.message
|
|
9025
|
-
});
|
|
9026
|
-
} catch (err) {
|
|
9027
|
-
return c.json(
|
|
9028
|
-
{
|
|
9029
|
-
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9030
|
-
},
|
|
9031
|
-
500
|
|
9032
|
-
);
|
|
9033
|
-
}
|
|
9034
|
-
return c.json({ sent: true, inviteLink: inviteUrl.toString() });
|
|
9035
|
-
});
|
|
9036
|
-
return admin;
|
|
8929
|
+
if (bindError) fail("redeemDeviceCode", bindError);
|
|
8930
|
+
}
|
|
8931
|
+
return device;
|
|
9037
8932
|
}
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
// ../api/src/dashboard/data-api.ts
|
|
9044
|
-
import { existsSync as existsSync19 } from "fs";
|
|
9045
|
-
import sharp5 from "sharp";
|
|
9046
|
-
|
|
9047
|
-
// ../api/src/dashboard/automations-routes.ts
|
|
9048
|
-
import { Hono as Hono10 } from "hono";
|
|
9049
|
-
var TYPES2 = ["schedule", "trigger", "mention-trigger"];
|
|
9050
|
-
function isType(t) {
|
|
9051
|
-
return TYPES2.includes(t);
|
|
8933
|
+
async function getDevice(id) {
|
|
8934
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
|
|
8935
|
+
if (error2) fail("getDevice", error2);
|
|
8936
|
+
return data ? toDevice(data) : null;
|
|
9052
8937
|
}
|
|
9053
|
-
function
|
|
9054
|
-
const {
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
return rest;
|
|
8938
|
+
async function findDeviceByKey(publicKey, userId) {
|
|
8939
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("public_key", publicKey).eq("user_id", userId).is("revoked_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
8940
|
+
if (error2) fail("findDeviceByKey", error2);
|
|
8941
|
+
return data ? toDevice(data) : null;
|
|
9058
8942
|
}
|
|
9059
|
-
function
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
8943
|
+
async function issueAccessToken(deviceId) {
|
|
8944
|
+
const token = `fsd_${randomBytes4(32).toString("hex")}`;
|
|
8945
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({
|
|
8946
|
+
access_token_hash: sha256(token),
|
|
8947
|
+
access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
8948
|
+
last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
8949
|
+
}).eq("id", deviceId);
|
|
8950
|
+
if (error2) fail("issueAccessToken", error2);
|
|
8951
|
+
return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
|
|
9063
8952
|
}
|
|
9064
|
-
function
|
|
9065
|
-
|
|
9066
|
-
const
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
if (!item) return c.json({ error: "not found" }, 404);
|
|
9070
|
-
return c.json(item);
|
|
9071
|
-
});
|
|
9072
|
-
app.post("/:type", async (c) => {
|
|
9073
|
-
const type = c.req.param("type");
|
|
9074
|
-
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9075
|
-
const body = sanitize(await c.req.json());
|
|
9076
|
-
const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
9077
|
-
try {
|
|
9078
|
-
const m = manager();
|
|
9079
|
-
if (type === "schedule") await m.addSchedule(next);
|
|
9080
|
-
else if (type === "trigger") await m.addTrigger(next);
|
|
9081
|
-
else await m.addMentionTrigger(next);
|
|
9082
|
-
return c.json(next, 201);
|
|
9083
|
-
} catch (e) {
|
|
9084
|
-
const msg = e.message;
|
|
9085
|
-
const status = statusFor(msg);
|
|
9086
|
-
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9087
|
-
}
|
|
9088
|
-
});
|
|
9089
|
-
app.put("/:type/:name", async (c) => {
|
|
9090
|
-
const type = c.req.param("type");
|
|
9091
|
-
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9092
|
-
const name = c.req.param("name");
|
|
9093
|
-
const body = sanitize(await c.req.json());
|
|
9094
|
-
try {
|
|
9095
|
-
const m = manager();
|
|
9096
|
-
const existing = m.get(name);
|
|
9097
|
-
const next = {
|
|
9098
|
-
...body,
|
|
9099
|
-
created_at: existing?.created_at
|
|
9100
|
-
};
|
|
9101
|
-
await m.update(type, name, next);
|
|
9102
|
-
return c.json(next, 200);
|
|
9103
|
-
} catch (e) {
|
|
9104
|
-
const msg = e.message;
|
|
9105
|
-
const status = statusFor(msg);
|
|
9106
|
-
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9107
|
-
}
|
|
9108
|
-
});
|
|
9109
|
-
app.delete("/:type/:name", async (c) => {
|
|
9110
|
-
try {
|
|
9111
|
-
await manager().remove(c.req.param("name"));
|
|
9112
|
-
return c.json({ ok: true }, 200);
|
|
9113
|
-
} catch (e) {
|
|
9114
|
-
return c.json({ error: e.message }, 404);
|
|
9115
|
-
}
|
|
9116
|
-
});
|
|
9117
|
-
return app;
|
|
8953
|
+
async function deviceForAccessToken(token) {
|
|
8954
|
+
if (!token.startsWith("fsd_")) return null;
|
|
8955
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("access_token_hash", sha256(token)).is("revoked_at", null).gt("access_token_expires_at", (/* @__PURE__ */ new Date()).toISOString()).maybeSingle();
|
|
8956
|
+
if (error2) fail("deviceForAccessToken", error2);
|
|
8957
|
+
return data ? toDevice(data) : null;
|
|
9118
8958
|
}
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9132
|
-
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9133
|
-
const supabase = getSupabaseClient();
|
|
9134
|
-
const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
|
|
9135
|
-
const nextPosition = (last?.[0]?.position ?? -1) + 1;
|
|
9136
|
-
const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
|
|
9137
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9138
|
-
return c.json(data, 201);
|
|
9139
|
-
});
|
|
9140
|
-
app.patch("/reorder", async (c) => {
|
|
9141
|
-
const body = await c.req.json().catch(() => ({}));
|
|
9142
|
-
const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
|
|
9143
|
-
if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
|
|
9144
|
-
const supabase = getSupabaseClient();
|
|
9145
|
-
const results = await Promise.all(
|
|
9146
|
-
ids.map(
|
|
9147
|
-
(id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
|
|
9148
|
-
)
|
|
9149
|
-
);
|
|
9150
|
-
const failed2 = results.find((r) => r.error);
|
|
9151
|
-
if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
|
|
9152
|
-
return c.json({ ok: true });
|
|
9153
|
-
});
|
|
9154
|
-
app.put("/:id", async (c) => {
|
|
9155
|
-
const id = c.req.param("id");
|
|
9156
|
-
const body = await c.req.json().catch(() => ({}));
|
|
9157
|
-
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9158
|
-
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9159
|
-
const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
|
|
9160
|
-
if (error2) return c.json({ error: error2.message }, 404);
|
|
9161
|
-
return c.json(data);
|
|
9162
|
-
});
|
|
9163
|
-
app.delete("/:id", async (c) => {
|
|
9164
|
-
const id = c.req.param("id");
|
|
9165
|
-
const supabase = getSupabaseClient();
|
|
9166
|
-
const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
|
|
9167
|
-
if (unsetError) return c.json({ error: unsetError.message }, 500);
|
|
9168
|
-
await refreshChannelRegistry();
|
|
9169
|
-
const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
|
|
9170
|
-
if (error2) return c.json({ error: error2.message }, 404);
|
|
9171
|
-
return c.json({ ok: true });
|
|
8959
|
+
async function revokeDevice2(id) {
|
|
8960
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
|
|
8961
|
+
if (error2) fail("revokeDevice", error2);
|
|
8962
|
+
}
|
|
8963
|
+
async function createChallenge(deviceId) {
|
|
8964
|
+
const db = getSupabaseClient();
|
|
8965
|
+
await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8966
|
+
const nonce = randomBytes4(32).toString("base64url");
|
|
8967
|
+
const { error: error2 } = await db.from("device_challenges").insert({
|
|
8968
|
+
nonce,
|
|
8969
|
+
device_id: deviceId,
|
|
8970
|
+
expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
|
|
9172
8971
|
});
|
|
9173
|
-
|
|
8972
|
+
if (error2) fail("createChallenge", error2);
|
|
8973
|
+
return nonce;
|
|
9174
8974
|
}
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
// ../api/src/dashboard/fork-summary.ts
|
|
9180
|
-
var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9181
|
-
var SUMMARY_MODEL = "openai/gpt-oss-120b";
|
|
9182
|
-
var MAX_INPUT_CHARS2 = 24e3;
|
|
9183
|
-
var TITLE_MAX_CHARS = 80;
|
|
9184
|
-
var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
|
|
9185
|
-
|
|
9186
|
-
Rules:
|
|
9187
|
-
- Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9188
|
-
- The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
|
|
9189
|
-
- Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
|
|
9190
|
-
- Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
|
|
9191
|
-
- Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
|
|
9192
|
-
- No preamble like "Summary:" \u2014 start directly with the title line.`;
|
|
9193
|
-
var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
|
|
9194
|
-
|
|
9195
|
-
Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
|
|
9196
|
-
|
|
9197
|
-
Shape:
|
|
9198
|
-
- A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
|
|
9199
|
-
- Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
|
|
9200
|
-
|
|
9201
|
-
Content:
|
|
9202
|
-
- Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9203
|
-
- Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
|
|
9204
|
-
- Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
|
|
9205
|
-
- Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
|
|
9206
|
-
|
|
9207
|
-
Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
|
|
9208
|
-
admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
|
|
9209
|
-
function clampTitle(title) {
|
|
9210
|
-
return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
|
|
8975
|
+
async function consumeChallenge(deviceId, nonce) {
|
|
8976
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_challenges").delete().eq("nonce", nonce).eq("device_id", deviceId).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).select("nonce");
|
|
8977
|
+
if (error2) fail("consumeChallenge", error2);
|
|
8978
|
+
return (data ?? []).length > 0;
|
|
9211
8979
|
}
|
|
9212
|
-
function
|
|
9213
|
-
const
|
|
9214
|
-
|
|
8980
|
+
async function listAdminWorkspaces(userId) {
|
|
8981
|
+
const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
|
|
8982
|
+
if (error2) fail("listAdminWorkspaces", error2);
|
|
8983
|
+
return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
9215
8984
|
}
|
|
9216
|
-
function
|
|
9217
|
-
const
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
);
|
|
8985
|
+
async function listDeviceWorkspaces(device) {
|
|
8986
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
|
|
8987
|
+
if (error2) fail("listDeviceWorkspaces", error2);
|
|
8988
|
+
const bound = new Set((data ?? []).map((r) => r.workspace_id));
|
|
8989
|
+
return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
|
|
9221
8990
|
}
|
|
9222
|
-
function
|
|
9223
|
-
|
|
8991
|
+
async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
|
|
8992
|
+
const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
|
|
8993
|
+
{ device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
|
|
8994
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8995
|
+
);
|
|
8996
|
+
if (error2) fail("bindDeviceWorkspace", error2);
|
|
9224
8997
|
}
|
|
9225
|
-
function
|
|
9226
|
-
const
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
const title = text.slice(0, breakAt).trim();
|
|
9230
|
-
const body = text.slice(breakAt + 1).trim();
|
|
9231
|
-
if (!title || !body) return fallbackTopic(original);
|
|
9232
|
-
return {
|
|
9233
|
-
title: clampTitle(title),
|
|
9234
|
-
body
|
|
9235
|
-
};
|
|
8998
|
+
async function listWorkspaceDevices(workspaceId) {
|
|
8999
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
|
|
9000
|
+
if (error2) fail("listWorkspaceDevices", error2);
|
|
9001
|
+
return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
9236
9002
|
}
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
const
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
model: SUMMARY_MODEL,
|
|
9249
|
-
temperature: 0.2,
|
|
9250
|
-
// A title has to sort the message's central claim from the next-step
|
|
9251
|
-
// paragraph it often ends with; 'low' reliably grabs the latter.
|
|
9252
|
-
reasoning_effort: focus ? "medium" : "low",
|
|
9253
|
-
// A title is one short line, but focus mode reasons before answering
|
|
9254
|
-
// and the budget covers reasoning tokens too — too small a cap here
|
|
9255
|
-
// truncates the title mid-word rather than failing.
|
|
9256
|
-
max_tokens: focus ? 512 : 1024,
|
|
9257
|
-
messages: [
|
|
9258
|
-
{
|
|
9259
|
-
role: "system",
|
|
9260
|
-
content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
|
|
9261
|
-
},
|
|
9262
|
-
{ role: "user", content: input2 }
|
|
9263
|
-
]
|
|
9264
|
-
})
|
|
9265
|
-
});
|
|
9266
|
-
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9267
|
-
const data = await res.json();
|
|
9268
|
-
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
9269
|
-
if (!raw) throw new Error("Groq returned an empty summary");
|
|
9270
|
-
if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
|
|
9271
|
-
return parseTopic(raw, fallback);
|
|
9272
|
-
} catch (err) {
|
|
9273
|
-
console.error("Fork summary failed, using original text:", err);
|
|
9274
|
-
return fallbackTopic(fallback);
|
|
9003
|
+
var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
|
|
9004
|
+
var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
|
|
9005
|
+
async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
|
|
9006
|
+
const hosts2 = /* @__PURE__ */ new Map();
|
|
9007
|
+
if (!workspaceIds.length) return hosts2;
|
|
9008
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
|
|
9009
|
+
if (error2) fail("listWorkspaceHosts", error2);
|
|
9010
|
+
for (const r of data ?? []) {
|
|
9011
|
+
if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
|
|
9012
|
+
hosts2.set(r.workspace_id, r.devices.name);
|
|
9013
|
+
}
|
|
9275
9014
|
}
|
|
9015
|
+
return hosts2;
|
|
9276
9016
|
}
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
}
|
|
9287
|
-
function resolveSlackTokens(text) {
|
|
9288
|
-
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
|
|
9017
|
+
async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
|
|
9018
|
+
if (!workspaceIds.length) return;
|
|
9019
|
+
const db = getSupabaseClient();
|
|
9020
|
+
const { data, error: error2 } = await db.from("device_workspaces").select("device_id, workspace_id, devices!inner(public_key, last_seen_at)").in("workspace_id", workspaceIds);
|
|
9021
|
+
if (error2) fail("releaseStaleHosts", error2);
|
|
9022
|
+
for (const r of data ?? []) {
|
|
9023
|
+
if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
|
|
9024
|
+
const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
|
|
9025
|
+
if (delError) fail("releaseStaleHosts", delError);
|
|
9026
|
+
}
|
|
9289
9027
|
}
|
|
9290
|
-
function
|
|
9291
|
-
|
|
9028
|
+
async function userEmail(userId) {
|
|
9029
|
+
const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
|
|
9030
|
+
if (error2) return null;
|
|
9031
|
+
return data.user?.email ?? null;
|
|
9292
9032
|
}
|
|
9293
9033
|
|
|
9294
|
-
// ../api/src/dashboard/
|
|
9295
|
-
|
|
9296
|
-
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9297
|
-
const { data, error: error2 } = await supabase.rpc("relevant_threads", {
|
|
9298
|
-
handle,
|
|
9299
|
-
mention_pattern: `@${escaped}\\M`,
|
|
9300
|
-
channel_ids: channelIds,
|
|
9301
|
-
before: opts.before ?? null,
|
|
9302
|
-
page_limit: opts.limit
|
|
9303
|
-
});
|
|
9304
|
-
if (error2) throw new Error(`relevant_threads: ${error2.message}`);
|
|
9305
|
-
return data ?? [];
|
|
9306
|
-
}
|
|
9034
|
+
// ../api/src/dashboard/onboarding-routes.ts
|
|
9035
|
+
import { Hono as Hono7 } from "hono";
|
|
9307
9036
|
|
|
9308
|
-
// ../api/src/dashboard/
|
|
9309
|
-
import {
|
|
9037
|
+
// ../api/src/dashboard/agent-bridge-data.ts
|
|
9038
|
+
import { randomBytes as randomBytes5, randomUUID as randomUUID2, verify as verifyRaw } from "crypto";
|
|
9039
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
9310
9040
|
|
|
9311
|
-
// ../api/src/
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
}
|
|
9326
|
-
return found;
|
|
9327
|
-
}
|
|
9328
|
-
async function putThreadTranslations(lang, rows) {
|
|
9329
|
-
if (rows.length === 0) return;
|
|
9330
|
-
const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
|
|
9331
|
-
rows.map((r) => ({
|
|
9332
|
-
reply_id: r.replyId,
|
|
9333
|
-
lang,
|
|
9334
|
-
body_hash: r.bodyHash,
|
|
9335
|
-
body: r.body,
|
|
9336
|
-
passthrough: r.passthrough
|
|
9337
|
-
})),
|
|
9338
|
-
{ onConflict: "reply_id,lang,body_hash" }
|
|
9339
|
-
);
|
|
9340
|
-
if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
|
|
9041
|
+
// ../api/src/dashboard/host-identity.ts
|
|
9042
|
+
import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey3, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
|
|
9043
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
|
|
9044
|
+
import { dirname as dirname5, join as join17 } from "path";
|
|
9045
|
+
function hostPrivateKeyPath() {
|
|
9046
|
+
return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
|
|
9047
|
+
}
|
|
9048
|
+
function loadHostPrivateKey(path = hostPrivateKeyPath()) {
|
|
9049
|
+
if (!existsSync17(path)) {
|
|
9050
|
+
throw new Error(
|
|
9051
|
+
`No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
|
|
9052
|
+
);
|
|
9053
|
+
}
|
|
9054
|
+
return createPrivateKey2(readFileSync16(path, "utf8"));
|
|
9341
9055
|
}
|
|
9342
9056
|
|
|
9343
|
-
// ../
|
|
9344
|
-
var
|
|
9345
|
-
var
|
|
9346
|
-
var
|
|
9347
|
-
var
|
|
9348
|
-
var
|
|
9349
|
-
var
|
|
9350
|
-
var
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9057
|
+
// ../core/src/bridge-protocol.ts
|
|
9058
|
+
var HOST_SOCKET_PATH = "/internal/host-socket";
|
|
9059
|
+
var DEFAULT_DATA_API_PORT = 4757;
|
|
9060
|
+
var CALL_TIMEOUT_MS = 15e3;
|
|
9061
|
+
var HEARTBEAT_INTERVAL_MS = 15e3;
|
|
9062
|
+
var HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 2.5;
|
|
9063
|
+
var LOCAL_RELAY_PATH = "/internal/agent-call";
|
|
9064
|
+
var RELAYED_METHODS = [
|
|
9065
|
+
"onChatMessage",
|
|
9066
|
+
"isAgentThinking",
|
|
9067
|
+
"getAgentActivities",
|
|
9068
|
+
"cancelAgentRun",
|
|
9069
|
+
"regenerateAgentRun",
|
|
9070
|
+
"listAgentRuns"
|
|
9071
|
+
];
|
|
9072
|
+
function encodeWireMessage(msg) {
|
|
9073
|
+
return JSON.stringify(msg);
|
|
9354
9074
|
}
|
|
9355
|
-
function
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9075
|
+
function parseWireMessage(raw) {
|
|
9076
|
+
try {
|
|
9077
|
+
return JSON.parse(String(raw));
|
|
9078
|
+
} catch {
|
|
9079
|
+
return null;
|
|
9080
|
+
}
|
|
9359
9081
|
}
|
|
9360
|
-
function systemPrompt2(languageName2) {
|
|
9361
|
-
return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
|
|
9362
|
-
|
|
9363
|
-
You receive a JSON object with two keys:
|
|
9364
|
-
- "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
|
|
9365
|
-
- "translate": the messages to translate, each with an "n" and a "body".
|
|
9366
|
-
|
|
9367
|
-
Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
|
|
9368
|
-
{"messages":[{"n":1,"body":"...","already_target":false}]}
|
|
9369
9082
|
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
- Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
|
|
9377
|
-
- Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
|
|
9378
|
-
- Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
|
|
9379
|
-
}
|
|
9380
|
-
function normalizeHyphens2(text) {
|
|
9381
|
-
return text.replace(/‐|‑/g, "-");
|
|
9083
|
+
// ../api/src/dashboard/agent-bridge-data.ts
|
|
9084
|
+
var hosts = /* @__PURE__ */ new Map();
|
|
9085
|
+
var hostStatusListeners = /* @__PURE__ */ new Set();
|
|
9086
|
+
function notifyHostStatus() {
|
|
9087
|
+
const online = hosts.size > 0;
|
|
9088
|
+
for (const l of hostStatusListeners) l(online);
|
|
9382
9089
|
}
|
|
9383
|
-
function
|
|
9384
|
-
|
|
9090
|
+
function removeHost(ws) {
|
|
9091
|
+
if (hosts.delete(ws)) notifyHostStatus();
|
|
9385
9092
|
}
|
|
9386
|
-
function
|
|
9387
|
-
const
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
|
|
9393
|
-
batches.push(current);
|
|
9394
|
-
current = [];
|
|
9395
|
-
size = 0;
|
|
9396
|
-
}
|
|
9397
|
-
current.push(m);
|
|
9398
|
-
size += len;
|
|
9093
|
+
function disconnectHostDevice(deviceId) {
|
|
9094
|
+
for (const [ws, scope] of hosts) {
|
|
9095
|
+
if (scope.deviceId !== deviceId) continue;
|
|
9096
|
+
console.log(`[host-socket] closing connection for revoked device ${deviceId}`);
|
|
9097
|
+
removeHost(ws);
|
|
9098
|
+
ws.close(1008, "device revoked");
|
|
9399
9099
|
}
|
|
9400
|
-
if (current.length > 0) batches.push(current);
|
|
9401
|
-
return batches;
|
|
9402
9100
|
}
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
9412
|
-
|
|
9101
|
+
var LAST_SEEN_RESOLUTION_MS = 60 * 6e4;
|
|
9102
|
+
var lastCallError = /* @__PURE__ */ new Map();
|
|
9103
|
+
function getHostCallError(method, profile = getProfile()) {
|
|
9104
|
+
return lastCallError.get(`${profile}:${method}`) ?? null;
|
|
9105
|
+
}
|
|
9106
|
+
|
|
9107
|
+
// ../api/src/dashboard/unique-channel-name.ts
|
|
9108
|
+
function uniqueChannelName(raw, taken) {
|
|
9109
|
+
const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
9110
|
+
let name = base;
|
|
9111
|
+
for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
|
|
9112
|
+
taken.add(name);
|
|
9113
|
+
return name;
|
|
9114
|
+
}
|
|
9115
|
+
|
|
9116
|
+
// ../api/src/dashboard/workspace-greeting.ts
|
|
9117
|
+
var GROQ_API_URL3 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9118
|
+
var GREETING_MODEL = "openai/gpt-oss-20b";
|
|
9119
|
+
var TIMEOUT_MS = 6e3;
|
|
9120
|
+
var MAX_LISTED_CHANNELS = 8;
|
|
9121
|
+
var GUIDES = [
|
|
9122
|
+
"Welcome to Flyspace",
|
|
9123
|
+
"Working with the agent",
|
|
9124
|
+
"Channels and folders",
|
|
9125
|
+
"Docs",
|
|
9126
|
+
"Sharing docs",
|
|
9127
|
+
"Inviting your team",
|
|
9128
|
+
"Automations"
|
|
9129
|
+
];
|
|
9130
|
+
var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
|
|
9131
|
+
|
|
9132
|
+
- Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
|
|
9133
|
+
- Greet the owner by name and name the workspace.
|
|
9134
|
+
- Say that #general is public: everyone invited to the workspace can see it.
|
|
9135
|
+
- If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
|
|
9136
|
+
- Mention the trial or the missing Host only if the facts say so.
|
|
9137
|
+
- If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
|
|
9138
|
+
- Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
|
|
9139
|
+
function greetingButtons(input2) {
|
|
9140
|
+
const buttons = [];
|
|
9141
|
+
if (!input2.hostConnected) buttons.push("Connect a machine");
|
|
9142
|
+
if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
|
|
9143
|
+
if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
|
|
9144
|
+
if (input2.memberCount <= 1) buttons.push("Invite my team");
|
|
9145
|
+
buttons.push("Set up a daily digest");
|
|
9146
|
+
return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
|
|
9147
|
+
}
|
|
9148
|
+
function fallbackGreeting(input2) {
|
|
9149
|
+
const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
|
|
9150
|
+
return [
|
|
9151
|
+
`Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
|
|
9152
|
+
"This is #general. It is public, so everyone you invite to the workspace can see it.",
|
|
9153
|
+
channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
|
|
9154
|
+
input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
|
|
9155
|
+
"Mention the agent in any channel to get started."
|
|
9156
|
+
].filter(Boolean).join("\n\n");
|
|
9157
|
+
}
|
|
9158
|
+
async function groqGreeting(input2, apiKey) {
|
|
9159
|
+
const facts = [
|
|
9160
|
+
`Owner: ${input2.ownerName}`,
|
|
9161
|
+
`Workspace: ${input2.workspaceName}`,
|
|
9162
|
+
`Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
|
|
9163
|
+
`Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
|
|
9164
|
+
`Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
|
|
9165
|
+
`Members: ${input2.memberCount}`,
|
|
9166
|
+
`Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
|
|
9167
|
+
].join("\n");
|
|
9168
|
+
const res = await fetch(GROQ_API_URL3, {
|
|
9413
9169
|
method: "POST",
|
|
9170
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
9414
9171
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
9415
9172
|
body: JSON.stringify({
|
|
9416
|
-
model:
|
|
9417
|
-
temperature: 0,
|
|
9418
|
-
|
|
9419
|
-
|
|
9173
|
+
model: GREETING_MODEL,
|
|
9174
|
+
temperature: 0.6,
|
|
9175
|
+
// Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
|
|
9176
|
+
reasoning_effort: "low",
|
|
9177
|
+
max_tokens: 900,
|
|
9420
9178
|
messages: [
|
|
9421
|
-
{ role: "system", content:
|
|
9422
|
-
{ role: "user", content:
|
|
9179
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
9180
|
+
{ role: "user", content: facts }
|
|
9423
9181
|
]
|
|
9424
9182
|
})
|
|
9425
9183
|
});
|
|
9426
|
-
if (!res.ok) throw new Error(`
|
|
9184
|
+
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9427
9185
|
const data = await res.json();
|
|
9428
|
-
const
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
const byNumber = /* @__PURE__ */ new Map();
|
|
9441
|
-
for (const entry of parsed.messages ?? []) {
|
|
9442
|
-
if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
|
|
9186
|
+
const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
|
|
9187
|
+
return text || null;
|
|
9188
|
+
}
|
|
9189
|
+
async function buildGreeting(input2) {
|
|
9190
|
+
let text = null;
|
|
9191
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
9192
|
+
if (apiKey) {
|
|
9193
|
+
try {
|
|
9194
|
+
text = await groqGreeting(input2, apiKey);
|
|
9195
|
+
} catch (err) {
|
|
9196
|
+
console.error("Workspace greeting failed, using the fixed template:", err);
|
|
9197
|
+
}
|
|
9443
9198
|
}
|
|
9444
|
-
return
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
if (!body) return { id: m.id, body: m.body, passthrough: true };
|
|
9448
|
-
return {
|
|
9449
|
-
id: m.id,
|
|
9450
|
-
body: normalizeHyphens2(body),
|
|
9451
|
-
passthrough: entry?.already_target === true
|
|
9452
|
-
};
|
|
9453
|
-
});
|
|
9199
|
+
return `${text ?? fallbackGreeting(input2)}
|
|
9200
|
+
|
|
9201
|
+
[BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
|
|
9454
9202
|
}
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
|
|
9458
|
-
|
|
9459
|
-
|
|
9460
|
-
|
|
9461
|
-
|
|
9462
|
-
|
|
9463
|
-
|
|
9464
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
const
|
|
9469
|
-
|
|
9470
|
-
const
|
|
9471
|
-
if (
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9203
|
+
|
|
9204
|
+
// ../api/src/dashboard/onboarding-routes.ts
|
|
9205
|
+
var MAX_FOLDERS = 20;
|
|
9206
|
+
var CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
9207
|
+
function isWorkspaceOwner(ctx) {
|
|
9208
|
+
return ctx?.role === "admin" && !ctx.isSupport;
|
|
9209
|
+
}
|
|
9210
|
+
function hostUnavailable(c, agent, method) {
|
|
9211
|
+
return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
|
|
9212
|
+
}
|
|
9213
|
+
function createOnboardingRouter(agent) {
|
|
9214
|
+
const app = new Hono7();
|
|
9215
|
+
app.get("/scan", async (c) => {
|
|
9216
|
+
const ctx = await resolveAuthContext(c);
|
|
9217
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9218
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9219
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9220
|
+
return c.json(scan);
|
|
9221
|
+
});
|
|
9222
|
+
app.post("/channels", async (c) => {
|
|
9223
|
+
const ctx = await resolveAuthContext(c);
|
|
9224
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9225
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9226
|
+
const runner = body["agent"];
|
|
9227
|
+
const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
9228
|
+
if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
|
|
9229
|
+
const scan = await agent.scanOnboarding?.();
|
|
9230
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9231
|
+
const offered = new Map(scan.projects.map((p) => [p.path, p]));
|
|
9232
|
+
const unknown = folders.find((f) => !offered.has(f));
|
|
9233
|
+
if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9234
|
+
const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
|
|
9235
|
+
taken.add("general");
|
|
9236
|
+
const items = [{ name: "general" }];
|
|
9237
|
+
for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
|
|
9238
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), items);
|
|
9239
|
+
if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9240
|
+
if (runner === "claude" || runner === "codex") {
|
|
9241
|
+
await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
|
|
9242
|
+
}
|
|
9243
|
+
const created = [];
|
|
9244
|
+
for (const l of linked) {
|
|
9245
|
+
created.push(
|
|
9246
|
+
l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
|
|
9491
9247
|
);
|
|
9492
9248
|
}
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9249
|
+
await markWorkspaceProjectsSetup(ctx.workspaceId);
|
|
9250
|
+
return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
|
|
9251
|
+
});
|
|
9252
|
+
app.post("/link-folder", async (c) => {
|
|
9253
|
+
const ctx = await resolveAuthContext(c);
|
|
9254
|
+
if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
|
|
9255
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9256
|
+
const path = typeof body["path"] === "string" ? body["path"].trim() : "";
|
|
9257
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9258
|
+
if (!path || !CHANNEL_NAME_RE.test(name)) return c.json({ error: "path and a valid name are required" }, 400);
|
|
9259
|
+
if (getProfileChannelRegistry().some((ch) => ch.name === name)) {
|
|
9260
|
+
return c.json({ error: `#${name} already exists` }, 409);
|
|
9261
|
+
}
|
|
9262
|
+
if (body["manual"] === true) {
|
|
9263
|
+
const devices = await listWorkspaceDevices(ctx.workspaceId);
|
|
9264
|
+
if (!devices.some((d) => d.userId === ctx.userId)) {
|
|
9265
|
+
return c.json({ error: "a path can only be typed on your own machine" }, 403);
|
|
9266
|
+
}
|
|
9267
|
+
} else {
|
|
9268
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9269
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9270
|
+
if (!scan.projects.some((p) => p.path === path)) {
|
|
9271
|
+
return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9272
|
+
}
|
|
9273
|
+
}
|
|
9274
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), [{ name, path }]);
|
|
9275
|
+
if (!linked) {
|
|
9276
|
+
const hostError = getHostCallError("linkProjectFolders");
|
|
9277
|
+
if (hostError) return c.json({ error: hostError }, 409);
|
|
9278
|
+
return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9279
|
+
}
|
|
9280
|
+
return c.json({ cwd: linked[0].cwd });
|
|
9281
|
+
});
|
|
9282
|
+
app.post("/reset", async (c) => {
|
|
9283
|
+
const ctx = await resolveAuthContext(c);
|
|
9284
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9285
|
+
const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
|
|
9286
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
9287
|
+
await resetWorkspaceOnboarding(ctx.workspaceId);
|
|
9288
|
+
return c.json({ ok: true });
|
|
9289
|
+
});
|
|
9290
|
+
app.get("/greeting", async (c) => {
|
|
9291
|
+
const ctx = await resolveAuthContext(c);
|
|
9292
|
+
if (ctx?.role !== "admin") return c.json({ pending: false });
|
|
9293
|
+
const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
|
|
9294
|
+
const pending2 = !!ctx.projectsSetupAt && !greetedAt;
|
|
9295
|
+
if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
|
|
9296
|
+
const general = await ensureGeneralChannel();
|
|
9297
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
|
|
9298
|
+
return c.json({ pending: pending2, channelId: general.id, threadId });
|
|
9299
|
+
});
|
|
9300
|
+
app.post("/greeting", async (c) => {
|
|
9301
|
+
const ctx = await resolveAuthContext(c);
|
|
9302
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9303
|
+
const supabase = getSupabaseClient();
|
|
9304
|
+
const general = await ensureGeneralChannel();
|
|
9305
|
+
const ownerName = ctx.name?.trim() || ctx.handle;
|
|
9306
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
|
|
9307
|
+
if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
|
|
9308
|
+
const done = { channelId: general.id, threadId };
|
|
9309
|
+
const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9310
|
+
if (replied.data) return c.json(done);
|
|
9311
|
+
if (await getWorkspaceGreetedAt(ctx.workspaceId))
|
|
9312
|
+
return c.json({ error: "already greeted" }, 409);
|
|
9313
|
+
const [branding, billing, members] = await Promise.all([
|
|
9314
|
+
getWorkspaceBranding(getProfile()).catch(() => null),
|
|
9315
|
+
getWorkspaceBilling(ctx.workspaceId).catch(() => null),
|
|
9316
|
+
listMemberships(ctx.workspaceId).catch(() => [])
|
|
9317
|
+
]);
|
|
9318
|
+
const body = await buildGreeting({
|
|
9319
|
+
ownerName,
|
|
9320
|
+
workspaceName: branding?.displayName || getProfile(),
|
|
9321
|
+
trialing: billing?.planStatus === "trialing",
|
|
9322
|
+
hostConnected: !!ctx.agentConnectedAt,
|
|
9323
|
+
channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
|
|
9324
|
+
memberCount: members.filter((m) => !m.isSupport).length
|
|
9497
9325
|
});
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
}
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
|
|
9505
|
-
return
|
|
9506
|
-
}
|
|
9507
|
-
function sanitizeModel(v) {
|
|
9508
|
-
if (typeof v !== "string") return null;
|
|
9509
|
-
const model = v.trim().toLowerCase();
|
|
9510
|
-
return isAllowedModel(model) ? model : null;
|
|
9326
|
+
const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9327
|
+
if (again.data) return c.json(done);
|
|
9328
|
+
const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
|
|
9329
|
+
if (reply.error) return c.json({ error: reply.error.message }, 500);
|
|
9330
|
+
await markWorkspaceGreeted(ctx.workspaceId);
|
|
9331
|
+
return c.json(done, 201);
|
|
9332
|
+
});
|
|
9333
|
+
return app;
|
|
9511
9334
|
}
|
|
9512
|
-
async function
|
|
9513
|
-
const
|
|
9514
|
-
|
|
9335
|
+
async function ensureGreetingThread(workspaceId, channelId, ownerName) {
|
|
9336
|
+
const supabase = getSupabaseClient();
|
|
9337
|
+
const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
|
|
9338
|
+
const found = await existing();
|
|
9339
|
+
if (found.data) return found.data.id;
|
|
9340
|
+
const { data, error: error2 } = await supabase.from("threads").insert({
|
|
9341
|
+
channel_id: channelId,
|
|
9342
|
+
title: `Welcome to your Flyspace, ${ownerName}!`,
|
|
9343
|
+
author: AGENT_AUTHOR,
|
|
9344
|
+
client_key: workspaceId
|
|
9345
|
+
}).select("id").single();
|
|
9346
|
+
if (data) return data.id;
|
|
9347
|
+
return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
|
|
9515
9348
|
}
|
|
9516
|
-
|
|
9517
|
-
|
|
9349
|
+
|
|
9350
|
+
// ../api/src/dashboard/auth-routes.ts
|
|
9351
|
+
function createAuthRouter() {
|
|
9352
|
+
const auth = new Hono8();
|
|
9353
|
+
const emailedLink = (deliver) => async (c) => {
|
|
9354
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9355
|
+
const email = body.email?.trim().toLowerCase();
|
|
9356
|
+
if (!email) return c.json({ error: "email is required" }, 400);
|
|
9357
|
+
if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
|
|
9358
|
+
return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
|
|
9359
|
+
}
|
|
9360
|
+
const workspace = await getWorkspaceBySlug(getProfile());
|
|
9361
|
+
if (!workspace) return c.json({ error: "unknown workspace" }, 404);
|
|
9362
|
+
const membership = await getMembershipByEmail(workspace.id, email);
|
|
9363
|
+
if (!membership) {
|
|
9364
|
+
return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
|
|
9365
|
+
}
|
|
9366
|
+
try {
|
|
9367
|
+
await deliver(email, callbackUrl(c));
|
|
9368
|
+
} catch (err) {
|
|
9369
|
+
return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
|
|
9370
|
+
}
|
|
9371
|
+
return c.json({ sent: true });
|
|
9372
|
+
};
|
|
9373
|
+
auth.post("/login", emailedLink(deliverSignInLink));
|
|
9374
|
+
auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
|
|
9375
|
+
auth.get("/session", async (c) => {
|
|
9376
|
+
const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
|
|
9377
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9378
|
+
return c.json({
|
|
9379
|
+
email: ctx.email,
|
|
9380
|
+
handle: ctx.handle,
|
|
9381
|
+
role: ctx.role,
|
|
9382
|
+
name: ctx.name,
|
|
9383
|
+
avatarUrl: ctx.isSupport ? SUPPORT_AVATAR_URL : ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
|
|
9384
|
+
onboarded: ctx.onboarded,
|
|
9385
|
+
passwordSet: ctx.passwordSet,
|
|
9386
|
+
agentConnectedAt: ctx.agentConnectedAt,
|
|
9387
|
+
plan: ctx.plan ?? null,
|
|
9388
|
+
onboarding: { pending: onboardingPending(ctx) },
|
|
9389
|
+
owner: isWorkspaceOwner(ctx),
|
|
9390
|
+
// Only the Host's owner can run `flyspace upgrade` on it, so other
|
|
9391
|
+
// admins never see the upgrade banner. Support staff can tell them to.
|
|
9392
|
+
hostOwner: ctx.role === "admin" && (ctx.isSupport === true || (await listWorkspaceDevices(ctx.workspaceId)).some((d) => d.userId === ctx.userId))
|
|
9393
|
+
});
|
|
9394
|
+
});
|
|
9395
|
+
auth.patch("/handle", async (c) => {
|
|
9396
|
+
const ctx = await resolveAuthContext(c);
|
|
9397
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9398
|
+
if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
|
|
9399
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9400
|
+
const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
|
|
9401
|
+
if (!HANDLE_PATTERN.test(handle)) {
|
|
9402
|
+
return c.json(
|
|
9403
|
+
{ error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
|
|
9404
|
+
400
|
|
9405
|
+
);
|
|
9406
|
+
}
|
|
9407
|
+
try {
|
|
9408
|
+
await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
|
|
9409
|
+
} catch (err) {
|
|
9410
|
+
if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
|
|
9411
|
+
throw err;
|
|
9412
|
+
}
|
|
9413
|
+
return c.json({ handle });
|
|
9414
|
+
});
|
|
9415
|
+
auth.post("/logout", async (c) => {
|
|
9416
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9417
|
+
await supabase.auth.signOut();
|
|
9418
|
+
return c.json({ ok: true });
|
|
9419
|
+
});
|
|
9420
|
+
return auth;
|
|
9518
9421
|
}
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9422
|
+
|
|
9423
|
+
// ../api/src/dashboard/auth-middleware.ts
|
|
9424
|
+
var requireWorkspaceMembership = async (c, next) => {
|
|
9425
|
+
const ctx = await resolveAuthContext(c);
|
|
9426
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9427
|
+
c.set("auth", ctx);
|
|
9428
|
+
return next();
|
|
9429
|
+
};
|
|
9430
|
+
|
|
9431
|
+
// ../api/src/dashboard/billing-routes.ts
|
|
9432
|
+
import { Hono as Hono9 } from "hono";
|
|
9433
|
+
function isPlan(value) {
|
|
9434
|
+
return value === "local" || value === "cloud";
|
|
9523
9435
|
}
|
|
9524
|
-
|
|
9525
|
-
|
|
9436
|
+
var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
|
|
9437
|
+
async function requireActivePlan(c, next) {
|
|
9438
|
+
if (c.req.method === "OPTIONS") return next();
|
|
9439
|
+
if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
|
|
9440
|
+
const auth = c.get("auth");
|
|
9441
|
+
if (!auth) return next();
|
|
9442
|
+
if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
|
|
9443
|
+
return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
|
|
9444
|
+
}
|
|
9445
|
+
return next();
|
|
9526
9446
|
}
|
|
9527
|
-
function
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9531
|
-
|
|
9532
|
-
|
|
9533
|
-
|
|
9534
|
-
|
|
9535
|
-
|
|
9536
|
-
|
|
9537
|
-
|
|
9538
|
-
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9447
|
+
function createBillingRouter() {
|
|
9448
|
+
const billing = new Hono9();
|
|
9449
|
+
billing.get("/", async (c) => {
|
|
9450
|
+
const auth = c.get("auth");
|
|
9451
|
+
const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9452
|
+
let trialEnd = null;
|
|
9453
|
+
let plan = null;
|
|
9454
|
+
if (stripeSubscriptionId) {
|
|
9455
|
+
try {
|
|
9456
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9457
|
+
if (planStatus === "trialing" && sub.trial_end) {
|
|
9458
|
+
trialEnd = new Date(sub.trial_end * 1e3).toISOString();
|
|
9459
|
+
}
|
|
9460
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9461
|
+
} catch (err) {
|
|
9462
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9463
|
+
}
|
|
9464
|
+
}
|
|
9465
|
+
return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
|
|
9466
|
+
});
|
|
9467
|
+
billing.post("/checkout", async (c) => {
|
|
9468
|
+
const auth = c.get("auth");
|
|
9469
|
+
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9470
|
+
const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9471
|
+
if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
|
|
9472
|
+
let plan = null;
|
|
9473
|
+
if (stripeSubscriptionId) {
|
|
9474
|
+
try {
|
|
9475
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9476
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9477
|
+
} catch (err) {
|
|
9478
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
9481
|
+
const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
|
|
9482
|
+
const session = await getStripeClient().checkout.sessions.create({
|
|
9483
|
+
mode: "setup",
|
|
9484
|
+
currency: "usd",
|
|
9485
|
+
customer: stripeCustomerId,
|
|
9486
|
+
success_url: `${origin}/?billing=done`,
|
|
9487
|
+
cancel_url: `${origin}/`,
|
|
9488
|
+
metadata: { workspace_id: auth.workspaceId },
|
|
9489
|
+
...plan && {
|
|
9490
|
+
custom_text: {
|
|
9491
|
+
submit: {
|
|
9492
|
+
message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
|
|
9493
|
+
}
|
|
9494
|
+
}
|
|
9495
|
+
}
|
|
9496
|
+
});
|
|
9497
|
+
if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
|
|
9498
|
+
return c.json({ url: session.url });
|
|
9499
|
+
});
|
|
9500
|
+
return billing;
|
|
9542
9501
|
}
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
var BOARD_PAGE_SIZE_MAX = 100;
|
|
9551
|
-
function ownChannelIds(c) {
|
|
9552
|
-
const viewer = c.get("auth");
|
|
9502
|
+
|
|
9503
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9504
|
+
import { Hono as Hono10 } from "hono";
|
|
9505
|
+
|
|
9506
|
+
// ../api/src/dashboard/admin-middleware.ts
|
|
9507
|
+
function platformAdminEmails() {
|
|
9508
|
+
const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
|
|
9553
9509
|
return new Set(
|
|
9554
|
-
|
|
9510
|
+
raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
|
|
9555
9511
|
);
|
|
9556
9512
|
}
|
|
9557
|
-
async
|
|
9558
|
-
const
|
|
9559
|
-
const
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
listMembershipsByEmail(viewer.email)
|
|
9567
|
-
]);
|
|
9568
|
-
workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
|
|
9569
|
-
roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
|
|
9570
|
-
}
|
|
9571
|
-
for (const ch of channels) {
|
|
9572
|
-
const workspaceId = workspaceIdBySlug.get(ch.profile);
|
|
9573
|
-
const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
|
|
9574
|
-
if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
|
|
9575
|
-
const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
|
|
9576
|
-
map.set(ch.id, meta);
|
|
9577
|
-
if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
|
|
9513
|
+
var requirePlatformOperator = async (c, next) => {
|
|
9514
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9515
|
+
const {
|
|
9516
|
+
data: { user }
|
|
9517
|
+
} = await supabase.auth.getUser();
|
|
9518
|
+
if (!user?.email) return c.json({ error: "not signed in" }, 401);
|
|
9519
|
+
const email = user.email.trim().toLowerCase();
|
|
9520
|
+
if (!platformAdminEmails().has(email)) {
|
|
9521
|
+
return c.json({ error: "platform operator access required" }, 403);
|
|
9578
9522
|
}
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
async
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
)
|
|
9599
|
-
);
|
|
9600
|
-
const previewByThread = /* @__PURE__ */ new Map();
|
|
9601
|
-
for (const { data } of chunkResults) {
|
|
9602
|
-
for (const r of data ?? []) {
|
|
9603
|
-
const entry = previewByThread.get(r.thread_id);
|
|
9604
|
-
if (entry) {
|
|
9605
|
-
entry.count += 1;
|
|
9606
|
-
entry.lastAuthor = r.author;
|
|
9607
|
-
entry.participants.delete(r.author);
|
|
9608
|
-
entry.participants.set(r.author, r.external_user_id ?? null);
|
|
9609
|
-
} else
|
|
9610
|
-
previewByThread.set(r.thread_id, {
|
|
9611
|
-
count: 1,
|
|
9612
|
-
firstBody: r.body,
|
|
9613
|
-
lastAuthor: r.author,
|
|
9614
|
-
participants: /* @__PURE__ */ new Map()
|
|
9523
|
+
c.set("platformOperator", { userId: user.id, email });
|
|
9524
|
+
return next();
|
|
9525
|
+
};
|
|
9526
|
+
|
|
9527
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9528
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9529
|
+
function createAdminRouter() {
|
|
9530
|
+
const admin = new Hono10();
|
|
9531
|
+
admin.use("*", requirePlatformOperator);
|
|
9532
|
+
admin.get("/workspaces", async (c) => {
|
|
9533
|
+
const summaries = await listWorkspaceSummaries();
|
|
9534
|
+
const workspaces = await Promise.all(
|
|
9535
|
+
summaries.map(async (w) => {
|
|
9536
|
+
const agentModel = cachedProfileConfig(w.slug)?.agentModel;
|
|
9537
|
+
const r2Bytes = await Promise.all(
|
|
9538
|
+
["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
|
|
9539
|
+
).then(([a, b]) => a + b).catch((err) => {
|
|
9540
|
+
console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
|
|
9541
|
+
return null;
|
|
9615
9542
|
});
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
// the reader's own handle here means there is nothing new for them to
|
|
9626
|
-
// read, on this browser or any other (see web/src/lib/thread-unread).
|
|
9627
|
-
last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
|
|
9628
|
-
// Everyone who replied, most recent first, for the row's avatar stack.
|
|
9629
|
-
participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
|
|
9630
|
-
};
|
|
9543
|
+
return {
|
|
9544
|
+
...w,
|
|
9545
|
+
agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
|
|
9546
|
+
r2Bytes,
|
|
9547
|
+
flyVolumeBytes: null
|
|
9548
|
+
};
|
|
9549
|
+
})
|
|
9550
|
+
);
|
|
9551
|
+
return c.json({ workspaces });
|
|
9631
9552
|
});
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
(ch) => ch.id === channelId || ch.externalChannelId === channelId
|
|
9636
|
-
);
|
|
9637
|
-
if (!channel) return null;
|
|
9638
|
-
const supabase = getSupabaseClient();
|
|
9639
|
-
const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
|
|
9640
|
-
const existingCategories = extractCategories(
|
|
9641
|
-
(recent ?? []).flatMap((t) => [
|
|
9642
|
-
t.auto_title,
|
|
9643
|
-
t.title
|
|
9644
|
-
])
|
|
9645
|
-
);
|
|
9646
|
-
return buildAutoTitle({
|
|
9647
|
-
channelName: channel.name,
|
|
9648
|
-
channelPrompt: channel.prompt,
|
|
9649
|
-
workspacePrompt: loadWorkspacePrompt().prompt,
|
|
9650
|
-
titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
|
|
9651
|
-
existingCategories,
|
|
9652
|
-
body,
|
|
9653
|
-
scope
|
|
9553
|
+
admin.get("/invites", async (c) => {
|
|
9554
|
+
const invites = await listSetupInvites();
|
|
9555
|
+
return c.json({ invites });
|
|
9654
9556
|
});
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
const supabase = getSupabaseClient();
|
|
9661
|
-
await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
|
|
9662
|
-
}
|
|
9663
|
-
function createChatRouter(agent = {}) {
|
|
9664
|
-
const app = new Hono12();
|
|
9665
|
-
const {
|
|
9666
|
-
onChatMessage: onMessage,
|
|
9667
|
-
isAgentThinking,
|
|
9668
|
-
getAgentActivities,
|
|
9669
|
-
cancelAgentRun,
|
|
9670
|
-
regenerateAgentRun,
|
|
9671
|
-
listAgentRuns
|
|
9672
|
-
} = agent;
|
|
9673
|
-
app.post(
|
|
9674
|
-
"/threads/:id/agent-cancel",
|
|
9675
|
-
async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
|
|
9676
|
-
);
|
|
9677
|
-
app.post(
|
|
9678
|
-
"/threads/:id/agent-regenerate",
|
|
9679
|
-
async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
|
|
9680
|
-
);
|
|
9681
|
-
app.get(
|
|
9682
|
-
"/threads/:id/agent-status",
|
|
9683
|
-
async (c) => c.json({
|
|
9684
|
-
thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
|
|
9685
|
-
activities: await getAgentActivities?.(c.req.param("id")) ?? []
|
|
9686
|
-
})
|
|
9687
|
-
);
|
|
9688
|
-
app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
|
|
9689
|
-
app.get("/channels/:channelId/threads", async (c) => {
|
|
9690
|
-
const channelId = c.req.param("channelId");
|
|
9691
|
-
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
9692
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9693
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
9694
|
-
const before = c.req.query("before");
|
|
9695
|
-
const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
|
|
9696
|
-
const statusParam = c.req.query("status");
|
|
9697
|
-
const statusFilter = statusParam ? [
|
|
9698
|
-
...new Set(
|
|
9699
|
-
statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
|
|
9700
|
-
)
|
|
9701
|
-
] : [];
|
|
9702
|
-
const supabase = getSupabaseClient();
|
|
9703
|
-
let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
|
|
9704
|
-
if (before) query = query.lt(sortColumn, before);
|
|
9705
|
-
if (statusFilter.length) {
|
|
9706
|
-
const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
|
|
9707
|
-
const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
|
|
9708
|
-
if (includeNone && withoutNone.length) {
|
|
9709
|
-
query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
|
|
9710
|
-
} else if (includeNone) {
|
|
9711
|
-
query = query.is("board_status", null);
|
|
9712
|
-
} else {
|
|
9713
|
-
query = query.in("board_status", withoutNone);
|
|
9714
|
-
}
|
|
9557
|
+
admin.post("/setup-invites", async (c) => {
|
|
9558
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9559
|
+
const ownerEmail = body.ownerEmail?.trim().toLowerCase();
|
|
9560
|
+
if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
|
|
9561
|
+
return c.json({ error: "ownerEmail must be a valid email address" }, 400);
|
|
9715
9562
|
}
|
|
9716
|
-
const
|
|
9717
|
-
if (
|
|
9718
|
-
|
|
9719
|
-
const
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9723
|
-
|
|
9724
|
-
const
|
|
9725
|
-
|
|
9726
|
-
|
|
9727
|
-
|
|
9563
|
+
const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
|
|
9564
|
+
if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
|
|
9565
|
+
const ownerName = body.ownerName?.trim().slice(0, 80) || null;
|
|
9566
|
+
const message = body.message?.trim() || null;
|
|
9567
|
+
if (message && message.length > INVITE_MESSAGE_MAX) {
|
|
9568
|
+
return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
|
|
9569
|
+
}
|
|
9570
|
+
const operator = c.get("platformOperator");
|
|
9571
|
+
const token = await issueSetupInvite({
|
|
9572
|
+
email: ownerEmail,
|
|
9573
|
+
ownerName,
|
|
9574
|
+
plan,
|
|
9575
|
+
message,
|
|
9576
|
+
invitedBy: operator.userId
|
|
9577
|
+
});
|
|
9578
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9579
|
+
inviteUrl.searchParams.set("token", token);
|
|
9580
|
+
if (body.sendEmail === false) {
|
|
9581
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9582
|
+
}
|
|
9583
|
+
try {
|
|
9584
|
+
await deliverWorkspaceSetupInvite({
|
|
9585
|
+
email: ownerEmail,
|
|
9586
|
+
inviteLink: inviteUrl.toString(),
|
|
9587
|
+
message
|
|
9588
|
+
});
|
|
9589
|
+
} catch (err) {
|
|
9590
|
+
await deleteSetupInvite(token).catch(() => {
|
|
9591
|
+
});
|
|
9728
9592
|
return c.json(
|
|
9729
|
-
{
|
|
9730
|
-
|
|
9593
|
+
{
|
|
9594
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9595
|
+
},
|
|
9596
|
+
500
|
|
9731
9597
|
);
|
|
9732
9598
|
}
|
|
9733
|
-
|
|
9734
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
9735
|
-
const before = c.req.query("before");
|
|
9736
|
-
const supabase = getSupabaseClient();
|
|
9737
|
-
let query = supabase.from("threads").select("*").eq("channel_id", channelId);
|
|
9738
|
-
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
9739
|
-
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
9740
|
-
if (before) query = query.lt("updated_at", before);
|
|
9741
|
-
const { data: threads, error: error2 } = await query;
|
|
9742
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9743
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9744
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9745
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9746
|
-
return c.json({ threads: withPreview, nextCursor });
|
|
9599
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9747
9600
|
});
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
|
|
9601
|
+
admin.delete("/setup-invites/:id", async (c) => {
|
|
9602
|
+
await revokeSetupInvite(c.req.param("id"));
|
|
9603
|
+
return c.json({ revoked: true });
|
|
9604
|
+
});
|
|
9605
|
+
admin.post("/setup-invites/:id/resend", async (c) => {
|
|
9606
|
+
const operator = c.get("platformOperator");
|
|
9607
|
+
let token;
|
|
9608
|
+
let invite;
|
|
9609
|
+
try {
|
|
9610
|
+
({ token, invite } = await reissueSetupInvite(c.req.param("id")));
|
|
9611
|
+
} catch (err) {
|
|
9612
|
+
return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
|
|
9613
|
+
}
|
|
9614
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9615
|
+
inviteUrl.searchParams.set("token", token);
|
|
9616
|
+
if (c.req.query("email") === "false") {
|
|
9617
|
+
return c.json({ sent: false, inviteLink: inviteUrl.toString() });
|
|
9618
|
+
}
|
|
9619
|
+
try {
|
|
9620
|
+
await deliverWorkspaceSetupInvite({
|
|
9621
|
+
email: invite.email,
|
|
9622
|
+
inviteLink: inviteUrl.toString(),
|
|
9623
|
+
message: invite.message
|
|
9624
|
+
});
|
|
9625
|
+
} catch (err) {
|
|
9751
9626
|
return c.json(
|
|
9752
|
-
{
|
|
9753
|
-
|
|
9627
|
+
{
|
|
9628
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9629
|
+
},
|
|
9630
|
+
500
|
|
9754
9631
|
);
|
|
9755
9632
|
}
|
|
9756
|
-
|
|
9757
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
9758
|
-
const before = c.req.query("before");
|
|
9759
|
-
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
9760
|
-
const supabase = getSupabaseClient();
|
|
9761
|
-
let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
|
|
9762
|
-
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
9763
|
-
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
9764
|
-
if (before) query = query.lt("updated_at", before);
|
|
9765
|
-
const { data: threads, error: error2 } = await query;
|
|
9766
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9767
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9768
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9769
|
-
const labelled = meta ? withPreview.map((t) => {
|
|
9770
|
-
const channelMeta = meta.get(t.channel_id ?? "");
|
|
9771
|
-
return {
|
|
9772
|
-
...t,
|
|
9773
|
-
workspace: channelMeta?.workspace ?? null,
|
|
9774
|
-
channel_name: channelMeta?.channelName ?? null,
|
|
9775
|
-
agent_name: channelMeta?.agentName ?? null
|
|
9776
|
-
};
|
|
9777
|
-
}) : withPreview;
|
|
9778
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9779
|
-
return c.json({ threads: labelled, nextCursor });
|
|
9633
|
+
return c.json({ sent: true, inviteLink: inviteUrl.toString() });
|
|
9780
9634
|
});
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
|
|
9787
|
-
|
|
9788
|
-
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
|
|
9813
|
-
|
|
9814
|
-
|
|
9815
|
-
|
|
9816
|
-
}
|
|
9817
|
-
}
|
|
9818
|
-
const withReplies = threads.map((t) => {
|
|
9819
|
-
const channelMeta = meta?.get(t.channel_id);
|
|
9820
|
-
return {
|
|
9821
|
-
...t,
|
|
9822
|
-
...meta ? {
|
|
9823
|
-
workspace: channelMeta?.workspace ?? null,
|
|
9824
|
-
channel_name: channelMeta?.channelName ?? null,
|
|
9825
|
-
agent_name: channelMeta?.agentName ?? null
|
|
9826
|
-
} : {},
|
|
9827
|
-
replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
|
|
9828
|
-
...r,
|
|
9829
|
-
body: slackMrkdwnToMarkdown(r.body)
|
|
9830
|
-
}))
|
|
9831
|
-
};
|
|
9832
|
-
});
|
|
9833
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
9834
|
-
return c.json({ threads: withReplies, nextCursor });
|
|
9635
|
+
return admin;
|
|
9636
|
+
}
|
|
9637
|
+
|
|
9638
|
+
// ../api/src/dashboard/agent-identities.ts
|
|
9639
|
+
var BOT_LABEL = "Workspace Agent";
|
|
9640
|
+
var BOT_DM_KEY = "claude";
|
|
9641
|
+
|
|
9642
|
+
// ../api/src/dashboard/data-api.ts
|
|
9643
|
+
import { existsSync as existsSync19 } from "fs";
|
|
9644
|
+
import sharp5 from "sharp";
|
|
9645
|
+
|
|
9646
|
+
// ../api/src/dashboard/automations-routes.ts
|
|
9647
|
+
import { Hono as Hono11 } from "hono";
|
|
9648
|
+
var TYPES2 = ["schedule", "trigger", "mention-trigger"];
|
|
9649
|
+
function isType(t) {
|
|
9650
|
+
return TYPES2.includes(t);
|
|
9651
|
+
}
|
|
9652
|
+
function sanitize(body) {
|
|
9653
|
+
const { created_by: _b, created_at: _a, ...rest } = body;
|
|
9654
|
+
void _b;
|
|
9655
|
+
void _a;
|
|
9656
|
+
return rest;
|
|
9657
|
+
}
|
|
9658
|
+
function statusFor(message) {
|
|
9659
|
+
if (/already exists/.test(message)) return 409;
|
|
9660
|
+
if (/not found/.test(message)) return 404;
|
|
9661
|
+
return 400;
|
|
9662
|
+
}
|
|
9663
|
+
function createAutomationWriteRouter() {
|
|
9664
|
+
const app = new Hono11();
|
|
9665
|
+
const manager = () => new AutomationManager();
|
|
9666
|
+
app.get("/:type/:name", (c) => {
|
|
9667
|
+
const item = manager().get(c.req.param("name"));
|
|
9668
|
+
if (!item) return c.json({ error: "not found" }, 404);
|
|
9669
|
+
return c.json(item);
|
|
9835
9670
|
});
|
|
9836
|
-
app.
|
|
9837
|
-
|
|
9838
|
-
|
|
9671
|
+
app.post("/:type", async (c) => {
|
|
9672
|
+
const type = c.req.param("type");
|
|
9673
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9674
|
+
const body = sanitize(await c.req.json());
|
|
9675
|
+
const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
9676
|
+
try {
|
|
9677
|
+
const m = manager();
|
|
9678
|
+
if (type === "schedule") await m.addSchedule(next);
|
|
9679
|
+
else if (type === "trigger") await m.addTrigger(next);
|
|
9680
|
+
else await m.addMentionTrigger(next);
|
|
9681
|
+
return c.json(next, 201);
|
|
9682
|
+
} catch (e) {
|
|
9683
|
+
const msg = e.message;
|
|
9684
|
+
const status = statusFor(msg);
|
|
9685
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9839
9686
|
}
|
|
9840
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9841
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
9842
|
-
const before = c.req.query("before");
|
|
9843
|
-
const supabase = getSupabaseClient();
|
|
9844
|
-
let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
|
|
9845
|
-
if (before) query = query.lt("archived_at", before);
|
|
9846
|
-
const { data: threads, error: error2 } = await query;
|
|
9847
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9848
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9849
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9850
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
|
|
9851
|
-
return c.json({ threads: withPreview, nextCursor });
|
|
9852
|
-
});
|
|
9853
|
-
app.get("/threads/:id", async (c) => {
|
|
9854
|
-
const id = c.req.param("id");
|
|
9855
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9856
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
|
|
9857
|
-
const before = c.req.query("before");
|
|
9858
|
-
let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
|
|
9859
|
-
if (before) repliesQuery = repliesQuery.lt("created_at", before);
|
|
9860
|
-
const [thread, replies, opening] = await Promise.all([
|
|
9861
|
-
getSupabaseClient().from("threads").select("*").eq("id", id).single(),
|
|
9862
|
-
repliesQuery,
|
|
9863
|
-
// The opening message, fetched separately because the replies page
|
|
9864
|
-
// above is newest-first: on a long thread the opener isn't in it. It
|
|
9865
|
-
// is what the header falls back to on a thread with no title of its
|
|
9866
|
-
// own (see threadCrumbLabel) — a title is never written on the
|
|
9867
|
-
// thread's behalf, so an untitled row must still be able to show and
|
|
9868
|
-
// rename a topic.
|
|
9869
|
-
getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
|
|
9870
|
-
]);
|
|
9871
|
-
if (thread.error) return c.json({ error: thread.error.message }, 404);
|
|
9872
|
-
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
9873
|
-
const channelId = thread.data?.channel_id;
|
|
9874
|
-
const meta = await globalChannelMeta(c);
|
|
9875
|
-
const channelMeta = channelId ? meta.get(channelId) : void 0;
|
|
9876
|
-
if (!channelId || !channelMeta) {
|
|
9877
|
-
return c.json({ error: "not found" }, 404);
|
|
9878
|
-
}
|
|
9879
|
-
const page2 = replies.data ?? [];
|
|
9880
|
-
const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
|
|
9881
|
-
const formattedReplies = page2.reverse().map((r) => ({
|
|
9882
|
-
...r,
|
|
9883
|
-
body: slackMrkdwnToMarkdown(r.body)
|
|
9884
|
-
}));
|
|
9885
|
-
const openingBody = (opening.data ?? [])[0]?.body;
|
|
9886
|
-
return c.json({
|
|
9887
|
-
...thread.data,
|
|
9888
|
-
// Always stamped (not just on `?scope=all` feeds, see the `/threads`
|
|
9889
|
-
// route above) — the reply pane can be opened directly by id (global
|
|
9890
|
-
// board, permalink) without the active workspace's channel map ever
|
|
9891
|
-
// having loaded this thread's channel, so the header needs its own
|
|
9892
|
-
// workspace/channel label rather than depending on that cache.
|
|
9893
|
-
workspace: channelMeta.workspace,
|
|
9894
|
-
channel_name: channelMeta.channelName,
|
|
9895
|
-
agent_name: channelMeta.agentName,
|
|
9896
|
-
preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
|
|
9897
|
-
replies: formattedReplies,
|
|
9898
|
-
nextCursor
|
|
9899
|
-
});
|
|
9900
9687
|
});
|
|
9901
|
-
app.
|
|
9902
|
-
const
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
const
|
|
9906
|
-
getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
|
|
9907
|
-
getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
|
|
9908
|
-
]);
|
|
9909
|
-
if (thread.error) return c.json({ error: "not found" }, 404);
|
|
9910
|
-
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
9911
|
-
const channelId = thread.data?.channel_id;
|
|
9912
|
-
if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
|
|
9913
|
-
return c.json({ error: "not found" }, 404);
|
|
9914
|
-
}
|
|
9915
|
-
const messages = (replies.data ?? []).map(
|
|
9916
|
-
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
9917
|
-
);
|
|
9688
|
+
app.put("/:type/:name", async (c) => {
|
|
9689
|
+
const type = c.req.param("type");
|
|
9690
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9691
|
+
const name = c.req.param("name");
|
|
9692
|
+
const body = sanitize(await c.req.json());
|
|
9918
9693
|
try {
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
const
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
return c.json(
|
|
9932
|
-
{ error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
|
|
9933
|
-
400
|
|
9934
|
-
);
|
|
9694
|
+
const m = manager();
|
|
9695
|
+
const existing = m.get(name);
|
|
9696
|
+
const next = {
|
|
9697
|
+
...body,
|
|
9698
|
+
created_at: existing?.created_at
|
|
9699
|
+
};
|
|
9700
|
+
await m.update(type, name, next);
|
|
9701
|
+
return c.json(next, 200);
|
|
9702
|
+
} catch (e) {
|
|
9703
|
+
const msg = e.message;
|
|
9704
|
+
const status = statusFor(msg);
|
|
9705
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9935
9706
|
}
|
|
9936
|
-
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
9937
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
|
|
9938
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9939
|
-
return c.json(thread);
|
|
9940
9707
|
});
|
|
9941
|
-
app.
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
return c.json({
|
|
9708
|
+
app.delete("/:type/:name", async (c) => {
|
|
9709
|
+
try {
|
|
9710
|
+
await manager().remove(c.req.param("name"));
|
|
9711
|
+
return c.json({ ok: true }, 200);
|
|
9712
|
+
} catch (e) {
|
|
9713
|
+
return c.json({ error: e.message }, 404);
|
|
9947
9714
|
}
|
|
9948
|
-
const title = raw === null ? null : raw.trim();
|
|
9949
|
-
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
9950
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
|
|
9951
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9952
|
-
return c.json(thread);
|
|
9953
9715
|
});
|
|
9954
|
-
app
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9716
|
+
return app;
|
|
9717
|
+
}
|
|
9718
|
+
|
|
9719
|
+
// ../api/src/dashboard/categories-routes.ts
|
|
9720
|
+
import { Hono as Hono12 } from "hono";
|
|
9721
|
+
function createCategoryWriteRouter() {
|
|
9722
|
+
const app = new Hono12();
|
|
9723
|
+
app.get("/", async (c) => {
|
|
9724
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
|
|
9960
9725
|
if (error2) return c.json({ error: error2.message }, 500);
|
|
9961
|
-
|
|
9962
|
-
const title = await labelThread(thread.channel_id, transcript, "thread");
|
|
9963
|
-
if (!title) return c.json({ error: "could not generate a topic" }, 502);
|
|
9964
|
-
return c.json({ title });
|
|
9726
|
+
return c.json({ categories: data ?? [] });
|
|
9965
9727
|
});
|
|
9966
|
-
app.
|
|
9967
|
-
const
|
|
9968
|
-
const
|
|
9969
|
-
|
|
9970
|
-
const body = input2?.body;
|
|
9971
|
-
if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
|
|
9972
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
9973
|
-
}
|
|
9728
|
+
app.post("/", async (c) => {
|
|
9729
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9730
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9731
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9974
9732
|
const supabase = getSupabaseClient();
|
|
9975
|
-
const { data:
|
|
9976
|
-
|
|
9977
|
-
|
|
9978
|
-
if (existing.author !== author) {
|
|
9979
|
-
return c.json({ error: "only the author can edit a message" }, 403);
|
|
9980
|
-
}
|
|
9981
|
-
const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
|
|
9733
|
+
const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
|
|
9734
|
+
const nextPosition = (last?.[0]?.position ?? -1) + 1;
|
|
9735
|
+
const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
|
|
9982
9736
|
if (error2) return c.json({ error: error2.message }, 500);
|
|
9983
|
-
|
|
9984
|
-
const { data: latest2 } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
9985
|
-
if (latest2?.id === replyId) {
|
|
9986
|
-
await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
|
|
9987
|
-
});
|
|
9988
|
-
}
|
|
9989
|
-
}
|
|
9990
|
-
return c.json(reply);
|
|
9737
|
+
return c.json(data, 201);
|
|
9991
9738
|
});
|
|
9992
|
-
app.
|
|
9993
|
-
const
|
|
9994
|
-
const
|
|
9995
|
-
|
|
9996
|
-
const emoji = input2?.emoji;
|
|
9997
|
-
if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
|
|
9998
|
-
return c.json({ errors: ["author and emoji are required"] }, 400);
|
|
9999
|
-
}
|
|
9739
|
+
app.patch("/reorder", async (c) => {
|
|
9740
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9741
|
+
const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
|
|
9742
|
+
if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
|
|
10000
9743
|
const supabase = getSupabaseClient();
|
|
10001
|
-
const
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
} else {
|
|
10010
|
-
const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
|
|
10011
|
-
if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
|
|
10012
|
-
}
|
|
10013
|
-
const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
|
|
10014
|
-
if (reactionsError) return c.json({ error: reactionsError.message }, 500);
|
|
10015
|
-
return c.json({ reactions: reactions ?? [] });
|
|
9744
|
+
const results = await Promise.all(
|
|
9745
|
+
ids.map(
|
|
9746
|
+
(id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
|
|
9747
|
+
)
|
|
9748
|
+
);
|
|
9749
|
+
const failed2 = results.find((r) => r.error);
|
|
9750
|
+
if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
|
|
9751
|
+
return c.json({ ok: true });
|
|
10016
9752
|
});
|
|
10017
|
-
app.
|
|
10018
|
-
if (c.get("auth")?.role !== "admin") {
|
|
10019
|
-
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10020
|
-
}
|
|
9753
|
+
app.put("/:id", async (c) => {
|
|
10021
9754
|
const id = c.req.param("id");
|
|
10022
|
-
const
|
|
10023
|
-
const
|
|
10024
|
-
if (
|
|
10025
|
-
|
|
10026
|
-
}
|
|
10027
|
-
|
|
10028
|
-
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
|
|
10029
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
10030
|
-
return c.json(thread);
|
|
9755
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9756
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9757
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9758
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
|
|
9759
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9760
|
+
return c.json(data);
|
|
10031
9761
|
});
|
|
10032
|
-
app.
|
|
10033
|
-
const
|
|
10034
|
-
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10035
|
-
const input2 = await c.req.json();
|
|
10036
|
-
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10037
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10038
|
-
}
|
|
10039
|
-
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10040
|
-
const model = requestedModel(c, input2.model);
|
|
10041
|
-
const effort = requestedEffort(c, input2.effort);
|
|
9762
|
+
app.delete("/:id", async (c) => {
|
|
9763
|
+
const id = c.req.param("id");
|
|
10042
9764
|
const supabase = getSupabaseClient();
|
|
10043
|
-
|
|
10044
|
-
|
|
10045
|
-
|
|
10046
|
-
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
// opening message at display time (see threadCrumbLabel).
|
|
10050
|
-
title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
|
|
10051
|
-
author: input2.author,
|
|
10052
|
-
client_key: clientKey2
|
|
10053
|
-
}).select().single();
|
|
10054
|
-
if (threadError) {
|
|
10055
|
-
if (!clientKey2 || threadError.code !== "23505") {
|
|
10056
|
-
return c.json({ error: threadError.message }, 500);
|
|
10057
|
-
}
|
|
10058
|
-
const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
|
|
10059
|
-
if (!existing) return c.json({ error: threadError.message }, 500);
|
|
10060
|
-
thread = existing;
|
|
10061
|
-
replayed = true;
|
|
10062
|
-
} else {
|
|
10063
|
-
thread = inserted;
|
|
10064
|
-
}
|
|
10065
|
-
let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
|
|
10066
|
-
let replyCompleted = false;
|
|
10067
|
-
if (!reply) {
|
|
10068
|
-
const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
|
|
10069
|
-
thread_id: thread.id,
|
|
10070
|
-
author: input2.author,
|
|
10071
|
-
body: input2.body,
|
|
10072
|
-
client_key: clientKey2,
|
|
10073
|
-
model,
|
|
10074
|
-
effort
|
|
10075
|
-
}).select().single();
|
|
10076
|
-
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10077
|
-
reply = insertedReply;
|
|
10078
|
-
replyCompleted = true;
|
|
10079
|
-
}
|
|
10080
|
-
if (!replayed || replyCompleted) {
|
|
10081
|
-
onMessage?.({
|
|
10082
|
-
threadId: thread.id,
|
|
10083
|
-
replyId: reply.id,
|
|
10084
|
-
author: input2.author,
|
|
10085
|
-
body: input2.body,
|
|
10086
|
-
// From the row, not `model`/`effort`: on the replay path the reply
|
|
10087
|
-
// predates this request, so its own stored override is the source of
|
|
10088
|
-
// truth.
|
|
10089
|
-
model: reply["model"] ?? void 0,
|
|
10090
|
-
effort: effortFromRow(reply["effort"])
|
|
10091
|
-
});
|
|
10092
|
-
}
|
|
10093
|
-
if (!replayed && thread["title"] == null) {
|
|
10094
|
-
void generateAutoTitle(thread.id, channelId, input2.body).catch(
|
|
10095
|
-
(err) => console.error("Auto title failed:", err)
|
|
10096
|
-
);
|
|
10097
|
-
}
|
|
10098
|
-
return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
|
|
10099
|
-
});
|
|
10100
|
-
app.post("/replies/:replyId/fork", async (c) => {
|
|
10101
|
-
const replyId = c.req.param("replyId");
|
|
10102
|
-
const input2 = await c.req.json().catch(() => null);
|
|
10103
|
-
const author = input2?.author;
|
|
10104
|
-
if (!isNonEmptyString(author)) {
|
|
10105
|
-
return c.json({ errors: ["author is required"] }, 400);
|
|
10106
|
-
}
|
|
10107
|
-
const mode = input2?.mode === "focus" ? "focus" : "condense";
|
|
10108
|
-
const supabase = getSupabaseClient();
|
|
10109
|
-
const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10110
|
-
if (sourceError) return c.json({ error: sourceError.message }, 404);
|
|
10111
|
-
const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
|
|
10112
|
-
if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
|
|
10113
|
-
if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
|
|
10114
|
-
return c.json({ error: "not found" }, 404);
|
|
10115
|
-
}
|
|
10116
|
-
const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
|
|
10117
|
-
const priorRows = priorReplies ?? [];
|
|
10118
|
-
const sourceText = slackMrkdwnToMarkdown(source.body);
|
|
10119
|
-
const FOCUS_CONTEXT_BELOW_CHARS = 280;
|
|
10120
|
-
const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
|
|
10121
|
-
const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10122
|
-
const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
|
|
10123
|
-
const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
|
|
10124
|
-
const openingBody = `${body}
|
|
10125
|
-
|
|
10126
|
-
> \u2442 Forked from [a message by ${source.author}](${backlink})`;
|
|
10127
|
-
const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
|
|
10128
|
-
if (threadError) return c.json({ error: threadError.message }, 500);
|
|
10129
|
-
const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
|
|
10130
|
-
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10131
|
-
onMessage?.({
|
|
10132
|
-
threadId: thread.id,
|
|
10133
|
-
replyId: reply.id,
|
|
10134
|
-
author,
|
|
10135
|
-
body: openingBody
|
|
10136
|
-
});
|
|
10137
|
-
return c.json({ ...thread, replies: [reply] }, 201);
|
|
10138
|
-
});
|
|
10139
|
-
app.post("/threads/:id/replies", async (c) => {
|
|
10140
|
-
const threadId = c.req.param("id");
|
|
10141
|
-
const input2 = await c.req.json();
|
|
10142
|
-
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10143
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10144
|
-
}
|
|
10145
|
-
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10146
|
-
const effort = requestedEffort(c, input2.effort);
|
|
10147
|
-
if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
|
|
10148
|
-
const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
|
|
10149
|
-
const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
|
|
10150
|
-
thread_id: threadId,
|
|
10151
|
-
author: input2.author,
|
|
10152
|
-
body: input2.body,
|
|
10153
|
-
client_key: clientKey2,
|
|
10154
|
-
model,
|
|
10155
|
-
effort
|
|
10156
|
-
}).select().single();
|
|
10157
|
-
if (error2) {
|
|
10158
|
-
if (clientKey2 && error2.code === "23505") {
|
|
10159
|
-
const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
|
|
10160
|
-
if (existing) return c.json(existing, 200);
|
|
10161
|
-
}
|
|
10162
|
-
return c.json({ error: error2.message }, 500);
|
|
10163
|
-
}
|
|
10164
|
-
onMessage?.({
|
|
10165
|
-
threadId,
|
|
10166
|
-
replyId: data.id,
|
|
10167
|
-
author: input2.author,
|
|
10168
|
-
body: input2.body,
|
|
10169
|
-
model: data.model ?? void 0,
|
|
10170
|
-
effort: effortFromRow(data.effort)
|
|
10171
|
-
});
|
|
10172
|
-
return c.json(data, 201);
|
|
9765
|
+
const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
|
|
9766
|
+
if (unsetError) return c.json({ error: unsetError.message }, 500);
|
|
9767
|
+
await refreshChannelRegistry();
|
|
9768
|
+
const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
|
|
9769
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9770
|
+
return c.json({ ok: true });
|
|
10173
9771
|
});
|
|
10174
9772
|
return app;
|
|
10175
9773
|
}
|
|
10176
9774
|
|
|
10177
|
-
// ../api/src/dashboard/
|
|
9775
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10178
9776
|
import { Hono as Hono13 } from "hono";
|
|
10179
9777
|
|
|
10180
|
-
// ../api/src/dashboard/
|
|
10181
|
-
|
|
10182
|
-
var
|
|
10183
|
-
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
brandingEnabled: true,
|
|
10187
|
-
translationEnabled: false,
|
|
10188
|
-
translationLangs: []
|
|
10189
|
-
};
|
|
10190
|
-
var HTML_FEATURES = {
|
|
10191
|
-
audioEnabled: false,
|
|
10192
|
-
askEnabled: false,
|
|
10193
|
-
socialEnabled: false,
|
|
10194
|
-
brandingEnabled: false,
|
|
10195
|
-
translationEnabled: false,
|
|
10196
|
-
translationLangs: []
|
|
10197
|
-
};
|
|
10198
|
-
function resolveFeatures(requested, existing) {
|
|
10199
|
-
const base = existing ?? DEFAULT_FEATURES;
|
|
10200
|
-
return {
|
|
10201
|
-
audioEnabled: requested?.audio ?? base.audioEnabled,
|
|
10202
|
-
askEnabled: requested?.ask ?? base.askEnabled,
|
|
10203
|
-
socialEnabled: requested?.social ?? base.socialEnabled,
|
|
10204
|
-
brandingEnabled: requested?.branding ?? base.brandingEnabled,
|
|
10205
|
-
translationEnabled: requested?.translation ?? base.translationEnabled,
|
|
10206
|
-
// Replaced wholesale, not merged: the allowlist IS the answer to "which
|
|
10207
|
-
// languages does this link offer", and a merge would make removing one
|
|
10208
|
-
// impossible.
|
|
10209
|
-
translationLangs: requested?.translationLangs ?? base.translationLangs
|
|
10210
|
-
};
|
|
10211
|
-
}
|
|
10212
|
-
async function deleteShareAssets(keys) {
|
|
10213
|
-
const client2 = getR2Client();
|
|
10214
|
-
const bucket = getR2Bucket();
|
|
10215
|
-
for (const key of keys) {
|
|
10216
|
-
try {
|
|
10217
|
-
await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
|
|
10218
|
-
} catch {
|
|
10219
|
-
}
|
|
10220
|
-
}
|
|
10221
|
-
}
|
|
10222
|
-
async function updateShareFeatures(share, requested) {
|
|
10223
|
-
if (share.kind === "html") return share;
|
|
10224
|
-
if (share.kind === "excalidraw") {
|
|
10225
|
-
if (requested.branding === void 0) return share;
|
|
10226
|
-
return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
|
|
10227
|
-
}
|
|
10228
|
-
const features = resolveFeatures(requested, share);
|
|
10229
|
-
const updated = await updateDocShareFeatures(share.id, features);
|
|
10230
|
-
if (!updated) return null;
|
|
10231
|
-
if (features.audioEnabled) void queueShareAudio(updated);
|
|
10232
|
-
else if (share.audioEnabled) await dropShareAudio(share);
|
|
10233
|
-
return updated;
|
|
10234
|
-
}
|
|
9778
|
+
// ../api/src/dashboard/fork-summary.ts
|
|
9779
|
+
var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9780
|
+
var SUMMARY_MODEL = "openai/gpt-oss-120b";
|
|
9781
|
+
var MAX_INPUT_CHARS2 = 24e3;
|
|
9782
|
+
var TITLE_MAX_CHARS = 80;
|
|
9783
|
+
var CONDENSE_SYSTEM_PROMPT = `You turn a chat conversation transcript into a new discussion topic where the work continues. Output EXACTLY two parts separated by one blank line: the first line is a concise topic title (max ${TITLE_MAX_CHARS} characters, no quotes, no Markdown), then a handoff summary of the conversation so far.
|
|
10235
9784
|
|
|
10236
|
-
|
|
10237
|
-
|
|
10238
|
-
|
|
9785
|
+
Rules:
|
|
9786
|
+
- Keep the conversation's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9787
|
+
- The summary opens a new thread where the work picks up: state the goal, what has been done or decided so far, and what remains open or should happen next.
|
|
9788
|
+
- Weight the END of the transcript more heavily: later messages reflect the current state. If an earlier plan or decision was later changed, superseded, or abandoned, summarize it in one clause at most and lead with what it became. Never present a discarded early approach with the same weight as the latest one.
|
|
9789
|
+
- Summarize the WORK, not the run mechanics: ignore status noise such as stopped/cancelled/terminated-run markers (e.g. "\u23F9 Stopped by user."), error or retry notices, and progress placeholders. A run being cut short is never part of the topic \u2014 capture the progress that was made instead.
|
|
9790
|
+
- Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
|
|
9791
|
+
- No preamble like "Summary:" \u2014 start directly with the title line.`;
|
|
9792
|
+
var FOCUS_TITLE_SYSTEM_PROMPT = `You name a new discussion topic after one chat message that someone is branching off into a focused side-discussion.
|
|
10239
9793
|
|
|
10240
|
-
|
|
10241
|
-
import { createPrivateKey as createPrivateKey2, createPublicKey as createPublicKey2, generateKeyPairSync as generateKeyPairSync2 } from "crypto";
|
|
10242
|
-
import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
|
|
10243
|
-
import { dirname as dirname5, join as join17 } from "path";
|
|
10244
|
-
function hostPrivateKeyPath() {
|
|
10245
|
-
return process.env["FLYSPACE_HOST_KEY_PATH"] || join17(paths.BASE_CONFIG_DIR, "host.key");
|
|
10246
|
-
}
|
|
10247
|
-
function loadHostPrivateKey(path = hostPrivateKeyPath()) {
|
|
10248
|
-
if (!existsSync17(path)) {
|
|
10249
|
-
throw new Error(
|
|
10250
|
-
`No Host identity at ${path}. Run \`pnpm run generate-host-key\` on this machine, then set FLYSPACE_HOST_PUBLIC_KEY on the Data process to the printed public key.`
|
|
10251
|
-
);
|
|
10252
|
-
}
|
|
10253
|
-
return createPrivateKey2(readFileSync16(path, "utf8"));
|
|
10254
|
-
}
|
|
9794
|
+
Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
|
|
10255
9795
|
|
|
10256
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
}
|
|
10271
|
-
}
|
|
10272
|
-
var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
|
|
10273
|
-
function generateUserCode() {
|
|
10274
|
-
const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
10275
|
-
return `${pick()}-${pick()}`;
|
|
9796
|
+
Shape:
|
|
9797
|
+
- A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
|
|
9798
|
+
- Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
|
|
9799
|
+
|
|
9800
|
+
Content:
|
|
9801
|
+
- Keep the message's own language. If it is Traditional Chinese (Taiwan), stay in Traditional Chinese with Taiwan wording and keep technical terms in English.
|
|
9802
|
+
- Name the topic after the FINAL message in the input. Any earlier message is only context for what that message replies to, never the subject of the title.
|
|
9803
|
+
- Name that message's central point. A message that diagnoses something is named after the finding, even when its last paragraph proposes a next step \u2014 do not title it after the proposed action.
|
|
9804
|
+
- Never reframe it into a different question, never assert a conclusion the message does not reach, and never turn an open investigation into a settled recommendation or a list of steps.
|
|
9805
|
+
|
|
9806
|
+
Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
|
|
9807
|
+
admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
|
|
9808
|
+
function clampTitle(title) {
|
|
9809
|
+
return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
|
|
10276
9810
|
}
|
|
10277
|
-
function
|
|
10278
|
-
const
|
|
10279
|
-
|
|
10280
|
-
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
|
9811
|
+
function deriveTitle(body) {
|
|
9812
|
+
const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
|
|
9813
|
+
return clampTitle(line);
|
|
10281
9814
|
}
|
|
10282
|
-
function
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
platform: r["platform"] ?? null,
|
|
10288
|
-
publicKey: String(r["public_key"]),
|
|
10289
|
-
createdAt: String(r["created_at"]),
|
|
10290
|
-
lastSeenAt: r["last_seen_at"] ?? null,
|
|
10291
|
-
revokedAt: r["revoked_at"] ?? null
|
|
10292
|
-
};
|
|
9815
|
+
function cleanTitleLine(raw) {
|
|
9816
|
+
const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
9817
|
+
return clampTitle(
|
|
9818
|
+
line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
|
|
9819
|
+
);
|
|
10293
9820
|
}
|
|
10294
|
-
function
|
|
9821
|
+
function fallbackTopic(body) {
|
|
9822
|
+
return { title: deriveTitle(body), body };
|
|
9823
|
+
}
|
|
9824
|
+
function parseTopic(raw, original) {
|
|
9825
|
+
const text = raw.trim();
|
|
9826
|
+
const breakAt = text.indexOf("\n");
|
|
9827
|
+
if (breakAt === -1) return { title: deriveTitle(text), body: text };
|
|
9828
|
+
const title = text.slice(0, breakAt).trim();
|
|
9829
|
+
const body = text.slice(breakAt + 1).trim();
|
|
9830
|
+
if (!title || !body) return fallbackTopic(original);
|
|
10295
9831
|
return {
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
publicKey: String(r["public_key"]),
|
|
10299
|
-
name: String(r["name"]),
|
|
10300
|
-
platform: r["platform"] ?? null,
|
|
10301
|
-
requesterIp: r["requester_ip"] ?? null,
|
|
10302
|
-
approvedBy: r["approved_by"] ?? null,
|
|
10303
|
-
workspaceIds: r["workspace_ids"] ?? [],
|
|
10304
|
-
deniedAt: r["denied_at"] ?? null,
|
|
10305
|
-
lastPolledAt: r["last_polled_at"] ?? null,
|
|
10306
|
-
expiresAt: String(r["expires_at"]),
|
|
10307
|
-
createdAt: String(r["created_at"])
|
|
9832
|
+
title: clampTitle(title),
|
|
9833
|
+
body
|
|
10308
9834
|
};
|
|
10309
9835
|
}
|
|
10310
|
-
function
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
10314
|
-
const
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
9836
|
+
async function buildForkTopic(transcript, fallbackText, mode = "condense") {
|
|
9837
|
+
const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
|
|
9838
|
+
const fallback = fallbackText ?? transcript;
|
|
9839
|
+
const focus = mode === "focus";
|
|
9840
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
9841
|
+
if (!apiKey) return fallbackTopic(fallback);
|
|
9842
|
+
try {
|
|
9843
|
+
const res = await fetch(GROQ_API_URL4, {
|
|
9844
|
+
method: "POST",
|
|
9845
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
9846
|
+
body: JSON.stringify({
|
|
9847
|
+
model: SUMMARY_MODEL,
|
|
9848
|
+
temperature: 0.2,
|
|
9849
|
+
// A title has to sort the message's central claim from the next-step
|
|
9850
|
+
// paragraph it often ends with; 'low' reliably grabs the latter.
|
|
9851
|
+
reasoning_effort: focus ? "medium" : "low",
|
|
9852
|
+
// A title is one short line, but focus mode reasons before answering
|
|
9853
|
+
// and the budget covers reasoning tokens too — too small a cap here
|
|
9854
|
+
// truncates the title mid-word rather than failing.
|
|
9855
|
+
max_tokens: focus ? 512 : 1024,
|
|
9856
|
+
messages: [
|
|
9857
|
+
{
|
|
9858
|
+
role: "system",
|
|
9859
|
+
content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
|
|
9860
|
+
},
|
|
9861
|
+
{ role: "user", content: input2 }
|
|
9862
|
+
]
|
|
9863
|
+
})
|
|
10327
9864
|
});
|
|
10328
|
-
if (!
|
|
10329
|
-
|
|
9865
|
+
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9866
|
+
const data = await res.json();
|
|
9867
|
+
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
9868
|
+
if (!raw) throw new Error("Groq returned an empty summary");
|
|
9869
|
+
if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
|
|
9870
|
+
return parseTopic(raw, fallback);
|
|
9871
|
+
} catch (err) {
|
|
9872
|
+
console.error("Fork summary failed, using original text:", err);
|
|
9873
|
+
return fallbackTopic(fallback);
|
|
10330
9874
|
}
|
|
10331
|
-
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
10332
9875
|
}
|
|
10333
|
-
|
|
10334
|
-
|
|
10335
|
-
|
|
10336
|
-
|
|
9876
|
+
|
|
9877
|
+
// ../api/src/dashboard/slack-format.ts
|
|
9878
|
+
import { nameToEmoji } from "gemoji";
|
|
9879
|
+
var SLACK_EMOJI_ALIASES = {
|
|
9880
|
+
double_vertical_bar: "\u23F8\uFE0F"
|
|
9881
|
+
};
|
|
9882
|
+
function emojifyShortcode(code) {
|
|
9883
|
+
const name = code.slice(1, -1);
|
|
9884
|
+
return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
|
|
10337
9885
|
}
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
10341
|
-
return data ? toPending(data) : null;
|
|
9886
|
+
function resolveSlackTokens(text) {
|
|
9887
|
+
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${getLocalCache().displayNameFor(id)}`).replace(/<#([A-Z0-9]+)\|([^>]+)>/g, (_m, _id, name) => `#${name}`).replace(/<#([A-Z0-9]+)>/g, (_m, id) => `#${getLocalCache().lookupChannelById(id)?.name ?? id}`).replace(/<!channel>/g, "@channel").replace(/<!here>/g, "@here").replace(/<!everyone>/g, "@everyone").replace(/<!subteam\^[A-Z0-9]+\|@?([^>]+)>/g, "@$1").replace(/<mailto:([^|>]+)\|([^>]+)>/g, (_m, addr, label2) => `[${label2}](mailto:${addr})`).replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, (_m, url, label2) => `[${label2}](${url})`).replace(/:[a-z0-9_+-]+:/gi, emojifyShortcode);
|
|
10342
9888
|
}
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
if (error2) fail("markPolled", error2);
|
|
9889
|
+
function slackMrkdwnToMarkdown(text) {
|
|
9890
|
+
return resolveSlackTokens(text).replace(/(?<![\w*])\*(?!\*)(\S(?:[^*\n]*?\S)?)\*(?!\*)(?![\w*])/g, "**$1**").replace(/(?<![\w~])~(?!~)(\S(?:[^~\n]*?\S)?)~(?!~)(?![\w~])/g, "~~$1~~").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
10346
9891
|
}
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
9892
|
+
|
|
9893
|
+
// ../api/src/dashboard/thread-relevance.ts
|
|
9894
|
+
async function relevantThreadsPage(supabase, handle, channelIds, opts) {
|
|
9895
|
+
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9896
|
+
const { data, error: error2 } = await supabase.rpc("relevant_threads", {
|
|
9897
|
+
handle,
|
|
9898
|
+
mention_pattern: `@${escaped}\\M`,
|
|
9899
|
+
channel_ids: channelIds,
|
|
9900
|
+
before: opts.before ?? null,
|
|
9901
|
+
page_limit: opts.limit
|
|
9902
|
+
});
|
|
9903
|
+
if (error2) throw new Error(`relevant_threads: ${error2.message}`);
|
|
9904
|
+
return data ?? [];
|
|
10353
9905
|
}
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
10360
|
-
const
|
|
10361
|
-
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
10365
|
-
|
|
10366
|
-
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
workspace_id: workspaceId,
|
|
10373
|
-
bound_by: pending2.approvedBy
|
|
10374
|
-
})),
|
|
10375
|
-
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
10376
|
-
);
|
|
10377
|
-
if (bindError) fail("redeemDeviceCode", bindError);
|
|
9906
|
+
|
|
9907
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9908
|
+
import { createHash as createHash8 } from "crypto";
|
|
9909
|
+
|
|
9910
|
+
// ../api/src/store/thread-translation-store.ts
|
|
9911
|
+
async function getThreadTranslations(lang, keys) {
|
|
9912
|
+
const found = /* @__PURE__ */ new Map();
|
|
9913
|
+
if (keys.length === 0) return found;
|
|
9914
|
+
const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
|
|
9915
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
|
|
9916
|
+
if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
|
|
9917
|
+
for (const row of data ?? []) {
|
|
9918
|
+
const replyId = row["reply_id"];
|
|
9919
|
+
found.set(replyId, {
|
|
9920
|
+
replyId,
|
|
9921
|
+
body: row["body"],
|
|
9922
|
+
passthrough: row["passthrough"]
|
|
9923
|
+
});
|
|
10378
9924
|
}
|
|
10379
|
-
return
|
|
10380
|
-
}
|
|
10381
|
-
async function getDevice(id) {
|
|
10382
|
-
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
|
|
10383
|
-
if (error2) fail("getDevice", error2);
|
|
10384
|
-
return data ? toDevice(data) : null;
|
|
9925
|
+
return found;
|
|
10385
9926
|
}
|
|
10386
|
-
async function
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
9927
|
+
async function putThreadTranslations(lang, rows) {
|
|
9928
|
+
if (rows.length === 0) return;
|
|
9929
|
+
const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
|
|
9930
|
+
rows.map((r) => ({
|
|
9931
|
+
reply_id: r.replyId,
|
|
9932
|
+
lang,
|
|
9933
|
+
body_hash: r.bodyHash,
|
|
9934
|
+
body: r.body,
|
|
9935
|
+
passthrough: r.passthrough
|
|
9936
|
+
})),
|
|
9937
|
+
{ onConflict: "reply_id,lang,body_hash" }
|
|
9938
|
+
);
|
|
9939
|
+
if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
|
|
10390
9940
|
}
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
9941
|
+
|
|
9942
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9943
|
+
var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9944
|
+
var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
|
|
9945
|
+
var RECIPE_VERSION3 = 1;
|
|
9946
|
+
var BATCH_CHAR_BUDGET = 16e3;
|
|
9947
|
+
var CONTEXT_MESSAGES = 4;
|
|
9948
|
+
var MAX_MESSAGE_CHARS = 12e3;
|
|
9949
|
+
var MAX_OUTPUT_TOKENS2 = 32768;
|
|
9950
|
+
var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
|
|
9951
|
+
function isTranslatableLanguage2(lang) {
|
|
9952
|
+
return BY_CODE2.has(lang);
|
|
10400
9953
|
}
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
return data ? toDevice(data) : null;
|
|
9954
|
+
function translationHash2(author, body) {
|
|
9955
|
+
return createHash8("sha256").update(`${RECIPE_VERSION3}
|
|
9956
|
+
${author}
|
|
9957
|
+
${body}`).digest("hex");
|
|
10406
9958
|
}
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
|
|
9959
|
+
function systemPrompt2(languageName2) {
|
|
9960
|
+
return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
|
|
9961
|
+
|
|
9962
|
+
You receive a JSON object with two keys:
|
|
9963
|
+
- "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
|
|
9964
|
+
- "translate": the messages to translate, each with an "n" and a "body".
|
|
9965
|
+
|
|
9966
|
+
Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
|
|
9967
|
+
{"messages":[{"n":1,"body":"...","already_target":false}]}
|
|
9968
|
+
|
|
9969
|
+
Rules:
|
|
9970
|
+
- "already_target" is true when the message is ALREADY written in ${languageName2}. In that case copy "body" through byte for byte, changing nothing at all. A message whose prose is in another language is NOT already_target just because it contains English technical terms, code, or product names.
|
|
9971
|
+
- Translate meaning, not words. These are people talking, not prose to be polished. Keep the speaker's register: a terse message stays terse, a casual one stays casual, a rude one stays rude. Do not make anyone sound more formal, more complete, or more articulate than they wrote.
|
|
9972
|
+
- A short reply ("\u597D\u554A", "same", "nope") is translated as the natural equivalent in ${languageName2}, using the context to see what it is answering. Never expand it into a full sentence the speaker did not write.
|
|
9973
|
+
- Keep the Markdown exactly as it is: same heading levels, list nesting, table shape, link and image syntax, blockquotes, and order. Do not add, merge, split, or reorder anything.
|
|
9974
|
+
- Do NOT translate: text inside fenced or inline code blocks, URLs, file paths, command names, or @mentions and /skill tokens. Leave them byte-for-byte identical.
|
|
9975
|
+
- Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
|
|
9976
|
+
- Use the punctuation conventions of ${languageName2}, including full-width punctuation for Chinese and Japanese. Use ordinary ASCII hyphens, never typographic or non-breaking hyphen variants.
|
|
9977
|
+
- Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
|
|
10410
9978
|
}
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
10418
|
-
|
|
9979
|
+
function normalizeHyphens2(text) {
|
|
9980
|
+
return text.replace(/‐|‑/g, "-");
|
|
9981
|
+
}
|
|
9982
|
+
function clip(body) {
|
|
9983
|
+
return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
|
|
9984
|
+
}
|
|
9985
|
+
function batchMessages(messages) {
|
|
9986
|
+
const batches = [];
|
|
9987
|
+
let current = [];
|
|
9988
|
+
let size = 0;
|
|
9989
|
+
for (const m of messages) {
|
|
9990
|
+
const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
|
|
9991
|
+
if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
|
|
9992
|
+
batches.push(current);
|
|
9993
|
+
current = [];
|
|
9994
|
+
size = 0;
|
|
9995
|
+
}
|
|
9996
|
+
current.push(m);
|
|
9997
|
+
size += len;
|
|
9998
|
+
}
|
|
9999
|
+
if (current.length > 0) batches.push(current);
|
|
10000
|
+
return batches;
|
|
10001
|
+
}
|
|
10002
|
+
async function translateBatch(batch, context, lang) {
|
|
10003
|
+
const language = BY_CODE2.get(lang);
|
|
10004
|
+
if (!language) throw new Error(`unsupported translation language: ${lang}`);
|
|
10005
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
10006
|
+
if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
|
|
10007
|
+
const payload = {
|
|
10008
|
+
context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
|
|
10009
|
+
translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
|
|
10010
|
+
};
|
|
10011
|
+
const res = await fetch(GROQ_API_URL5, {
|
|
10012
|
+
method: "POST",
|
|
10013
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10014
|
+
body: JSON.stringify({
|
|
10015
|
+
model: TRANSLATION_MODEL2,
|
|
10016
|
+
temperature: 0,
|
|
10017
|
+
max_tokens: MAX_OUTPUT_TOKENS2,
|
|
10018
|
+
response_format: { type: "json_object" },
|
|
10019
|
+
messages: [
|
|
10020
|
+
{ role: "system", content: systemPrompt2(language.name) },
|
|
10021
|
+
{ role: "user", content: JSON.stringify(payload) }
|
|
10022
|
+
]
|
|
10023
|
+
})
|
|
10024
|
+
});
|
|
10025
|
+
if (!res.ok) throw new Error(`translation model returned ${res.status}`);
|
|
10026
|
+
const data = await res.json();
|
|
10027
|
+
const choice = data.choices?.[0];
|
|
10028
|
+
if (choice?.finish_reason === "length") {
|
|
10029
|
+
throw new Error("the translation ran past the model output limit");
|
|
10030
|
+
}
|
|
10031
|
+
const raw = choice?.message?.content?.trim() ?? "";
|
|
10032
|
+
if (!raw) throw new Error("the model returned an empty translation");
|
|
10033
|
+
let parsed;
|
|
10034
|
+
try {
|
|
10035
|
+
parsed = JSON.parse(raw);
|
|
10036
|
+
} catch {
|
|
10037
|
+
throw new Error("the model returned unparseable JSON");
|
|
10038
|
+
}
|
|
10039
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
10040
|
+
for (const entry of parsed.messages ?? []) {
|
|
10041
|
+
if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
|
|
10042
|
+
}
|
|
10043
|
+
return batch.map((m, i) => {
|
|
10044
|
+
const entry = byNumber.get(i + 1);
|
|
10045
|
+
const body = typeof entry?.body === "string" ? entry.body.trim() : "";
|
|
10046
|
+
if (!body) return { id: m.id, body: m.body, passthrough: true };
|
|
10047
|
+
return {
|
|
10048
|
+
id: m.id,
|
|
10049
|
+
body: normalizeHyphens2(body),
|
|
10050
|
+
passthrough: entry?.already_target === true
|
|
10051
|
+
};
|
|
10052
|
+
});
|
|
10053
|
+
}
|
|
10054
|
+
var inFlight4 = /* @__PURE__ */ new Map();
|
|
10055
|
+
async function translateThread(threadId, lang, messages) {
|
|
10056
|
+
if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
|
|
10057
|
+
if (messages.length === 0) return [];
|
|
10058
|
+
const shape = createHash8("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
|
|
10059
|
+
const key = `${threadId}:${lang}:${shape}`;
|
|
10060
|
+
const running = inFlight4.get(key);
|
|
10061
|
+
if (running) return running;
|
|
10062
|
+
const job = (async () => {
|
|
10063
|
+
const hashes = messages.map((m) => ({
|
|
10064
|
+
replyId: m.id,
|
|
10065
|
+
bodyHash: translationHash2(m.author, m.body)
|
|
10066
|
+
}));
|
|
10067
|
+
const cached = await getThreadTranslations(lang, hashes);
|
|
10068
|
+
const missing = messages.filter((m) => !cached.has(m.id));
|
|
10069
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
10070
|
+
if (missing.length > 0) {
|
|
10071
|
+
const index = new Map(messages.map((m, i) => [m.id, i]));
|
|
10072
|
+
const batches = batchMessages(missing);
|
|
10073
|
+
const results = await Promise.all(
|
|
10074
|
+
batches.map((batch) => {
|
|
10075
|
+
const first = index.get(batch[0].id) ?? 0;
|
|
10076
|
+
const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
|
|
10077
|
+
return translateBatch(batch, context, lang);
|
|
10078
|
+
})
|
|
10079
|
+
);
|
|
10080
|
+
for (const row of results.flat()) fresh.set(row.id, row);
|
|
10081
|
+
const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
|
|
10082
|
+
await putThreadTranslations(
|
|
10083
|
+
lang,
|
|
10084
|
+
[...fresh.values()].map((r) => ({
|
|
10085
|
+
replyId: r.id,
|
|
10086
|
+
bodyHash: hashById.get(r.id),
|
|
10087
|
+
body: r.body,
|
|
10088
|
+
passthrough: r.passthrough
|
|
10089
|
+
}))
|
|
10090
|
+
);
|
|
10091
|
+
}
|
|
10092
|
+
return messages.map((m) => {
|
|
10093
|
+
const hit = cached.get(m.id);
|
|
10094
|
+
if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
|
|
10095
|
+
return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
|
|
10096
|
+
});
|
|
10097
|
+
})().finally(() => inFlight4.delete(key));
|
|
10098
|
+
inFlight4.set(key, job);
|
|
10099
|
+
return job;
|
|
10100
|
+
}
|
|
10101
|
+
|
|
10102
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10103
|
+
function isNonEmptyString(v) {
|
|
10104
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
10105
|
+
}
|
|
10106
|
+
function sanitizeModel(v) {
|
|
10107
|
+
if (typeof v !== "string") return null;
|
|
10108
|
+
const model = v.trim().toLowerCase();
|
|
10109
|
+
return isAllowedModel(model) ? model : null;
|
|
10110
|
+
}
|
|
10111
|
+
async function threadModel(threadId) {
|
|
10112
|
+
const { data } = await getSupabaseClient().from("thread_replies").select("model").eq("thread_id", threadId).not("model", "is", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
10113
|
+
return sanitizeModel(data?.model);
|
|
10114
|
+
}
|
|
10115
|
+
function requestedModel(c, model) {
|
|
10116
|
+
return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
|
|
10117
|
+
}
|
|
10118
|
+
function sanitizeEffort(v) {
|
|
10119
|
+
if (typeof v !== "string") return null;
|
|
10120
|
+
const effort = v.trim().toLowerCase();
|
|
10121
|
+
return isEffort(effort) ? effort : null;
|
|
10122
|
+
}
|
|
10123
|
+
function requestedEffort(c, effort) {
|
|
10124
|
+
return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
|
|
10125
|
+
}
|
|
10126
|
+
function effortFromRow(v) {
|
|
10127
|
+
return typeof v === "string" && isEffort(v) ? v : void 0;
|
|
10128
|
+
}
|
|
10129
|
+
var BOARD_STATUSES = [
|
|
10130
|
+
"backlog",
|
|
10131
|
+
"todo",
|
|
10132
|
+
"in_progress",
|
|
10133
|
+
"in_review",
|
|
10134
|
+
"done",
|
|
10135
|
+
"cancelled"
|
|
10136
|
+
];
|
|
10137
|
+
var NO_STATUS_COLUMN = "none";
|
|
10138
|
+
function boardColumnParam(status) {
|
|
10139
|
+
if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
|
|
10140
|
+
return BOARD_STATUSES.includes(status) ? status : null;
|
|
10141
|
+
}
|
|
10142
|
+
var THREAD_CHUNK_SIZE = 25;
|
|
10143
|
+
var THREADS_PAGE_SIZE = 30;
|
|
10144
|
+
var THREADS_PAGE_SIZE_MAX = 100;
|
|
10145
|
+
var REPLIES_PAGE_SIZE = 50;
|
|
10146
|
+
var REPLIES_PAGE_SIZE_MAX = 200;
|
|
10147
|
+
var TRANSLATABLE_REPLIES_MAX = 400;
|
|
10148
|
+
var BOARD_PAGE_SIZE = 50;
|
|
10149
|
+
var BOARD_PAGE_SIZE_MAX = 100;
|
|
10150
|
+
function ownChannelIds(c) {
|
|
10151
|
+
const viewer = c.get("auth");
|
|
10152
|
+
return new Set(
|
|
10153
|
+
getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
|
|
10154
|
+
);
|
|
10155
|
+
}
|
|
10156
|
+
async function globalChannelMeta(c) {
|
|
10157
|
+
const viewer = c.get("auth");
|
|
10158
|
+
const map = /* @__PURE__ */ new Map();
|
|
10159
|
+
const channels = getChannelRegistry();
|
|
10160
|
+
let workspaceIdBySlug = /* @__PURE__ */ new Map();
|
|
10161
|
+
let roleByWorkspaceId = /* @__PURE__ */ new Map();
|
|
10162
|
+
if (viewer?.email && channels.length > 0) {
|
|
10163
|
+
const [workspaces, memberships] = await Promise.all([
|
|
10164
|
+
listWorkspaces(),
|
|
10165
|
+
listMembershipsByEmail(viewer.email)
|
|
10166
|
+
]);
|
|
10167
|
+
workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
|
|
10168
|
+
roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
|
|
10169
|
+
}
|
|
10170
|
+
for (const ch of channels) {
|
|
10171
|
+
const workspaceId = workspaceIdBySlug.get(ch.profile);
|
|
10172
|
+
const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
|
|
10173
|
+
if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
|
|
10174
|
+
const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
|
|
10175
|
+
map.set(ch.id, meta);
|
|
10176
|
+
if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
|
|
10177
|
+
}
|
|
10178
|
+
return map;
|
|
10179
|
+
}
|
|
10180
|
+
async function threadIsKnown(threadId, c) {
|
|
10181
|
+
const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
|
|
10182
|
+
const channelId = data?.channel_id;
|
|
10183
|
+
return !!channelId && (await globalChannelMeta(c)).has(channelId);
|
|
10184
|
+
}
|
|
10185
|
+
function chunk(items, size) {
|
|
10186
|
+
const out = [];
|
|
10187
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
10188
|
+
return out;
|
|
10189
|
+
}
|
|
10190
|
+
async function withPreviews(supabase, threads) {
|
|
10191
|
+
const chunkResults = await Promise.all(
|
|
10192
|
+
chunk(
|
|
10193
|
+
threads.map((t) => t.id),
|
|
10194
|
+
THREAD_CHUNK_SIZE
|
|
10195
|
+
).map(
|
|
10196
|
+
(ids) => supabase.from("thread_replies").select("thread_id, body, author, external_user_id").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
10197
|
+
)
|
|
10198
|
+
);
|
|
10199
|
+
const previewByThread = /* @__PURE__ */ new Map();
|
|
10200
|
+
for (const { data } of chunkResults) {
|
|
10201
|
+
for (const r of data ?? []) {
|
|
10202
|
+
const entry = previewByThread.get(r.thread_id);
|
|
10203
|
+
if (entry) {
|
|
10204
|
+
entry.count += 1;
|
|
10205
|
+
entry.lastAuthor = r.author;
|
|
10206
|
+
entry.participants.delete(r.author);
|
|
10207
|
+
entry.participants.set(r.author, r.external_user_id ?? null);
|
|
10208
|
+
} else
|
|
10209
|
+
previewByThread.set(r.thread_id, {
|
|
10210
|
+
count: 1,
|
|
10211
|
+
firstBody: r.body,
|
|
10212
|
+
lastAuthor: r.author,
|
|
10213
|
+
participants: /* @__PURE__ */ new Map()
|
|
10214
|
+
});
|
|
10215
|
+
}
|
|
10216
|
+
}
|
|
10217
|
+
return threads.map((t) => {
|
|
10218
|
+
const entry = previewByThread.get(t.id);
|
|
10219
|
+
return {
|
|
10220
|
+
...t,
|
|
10221
|
+
reply_count: Math.max(0, (entry?.count ?? 0) - 1),
|
|
10222
|
+
preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
|
|
10223
|
+
// Who spoke last, counting only replies AFTER the opening message —
|
|
10224
|
+
// the reader's own handle here means there is nothing new for them to
|
|
10225
|
+
// read, on this browser or any other (see web/src/lib/thread-unread).
|
|
10226
|
+
last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
|
|
10227
|
+
// Everyone who replied, most recent first, for the row's avatar stack.
|
|
10228
|
+
participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
|
|
10229
|
+
};
|
|
10230
|
+
});
|
|
10231
|
+
}
|
|
10232
|
+
async function labelThread(channelId, body, scope) {
|
|
10233
|
+
const channel = getProfileChannelRegistry().find(
|
|
10234
|
+
(ch) => ch.id === channelId || ch.externalChannelId === channelId
|
|
10235
|
+
);
|
|
10236
|
+
if (!channel) return null;
|
|
10237
|
+
const supabase = getSupabaseClient();
|
|
10238
|
+
const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
|
|
10239
|
+
const existingCategories = extractCategories(
|
|
10240
|
+
(recent ?? []).flatMap((t) => [
|
|
10241
|
+
t.auto_title,
|
|
10242
|
+
t.title
|
|
10243
|
+
])
|
|
10244
|
+
);
|
|
10245
|
+
return buildAutoTitle({
|
|
10246
|
+
channelName: channel.name,
|
|
10247
|
+
channelPrompt: channel.prompt,
|
|
10248
|
+
workspacePrompt: loadWorkspacePrompt().prompt,
|
|
10249
|
+
titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
|
|
10250
|
+
existingCategories,
|
|
10251
|
+
body,
|
|
10252
|
+
scope
|
|
10253
|
+
});
|
|
10254
|
+
}
|
|
10255
|
+
async function generateAutoTitle(threadId, channelId, body) {
|
|
10256
|
+
if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
|
|
10257
|
+
const autoTitle = await labelThread(channelId, body);
|
|
10258
|
+
if (!autoTitle) return;
|
|
10259
|
+
const supabase = getSupabaseClient();
|
|
10260
|
+
await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
|
|
10261
|
+
}
|
|
10262
|
+
function createChatRouter(agent = {}) {
|
|
10263
|
+
const app = new Hono13();
|
|
10264
|
+
const {
|
|
10265
|
+
onChatMessage: onMessage,
|
|
10266
|
+
isAgentThinking,
|
|
10267
|
+
getAgentActivities,
|
|
10268
|
+
cancelAgentRun,
|
|
10269
|
+
regenerateAgentRun,
|
|
10270
|
+
listAgentRuns
|
|
10271
|
+
} = agent;
|
|
10272
|
+
app.post(
|
|
10273
|
+
"/threads/:id/agent-cancel",
|
|
10274
|
+
async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
|
|
10275
|
+
);
|
|
10276
|
+
app.post(
|
|
10277
|
+
"/threads/:id/agent-regenerate",
|
|
10278
|
+
async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
|
|
10279
|
+
);
|
|
10280
|
+
app.get(
|
|
10281
|
+
"/threads/:id/agent-status",
|
|
10282
|
+
async (c) => c.json({
|
|
10283
|
+
thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
|
|
10284
|
+
activities: await getAgentActivities?.(c.req.param("id")) ?? []
|
|
10285
|
+
})
|
|
10286
|
+
);
|
|
10287
|
+
app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
|
|
10288
|
+
app.get("/channels/:channelId/threads", async (c) => {
|
|
10289
|
+
const channelId = c.req.param("channelId");
|
|
10290
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10291
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10292
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10293
|
+
const before = c.req.query("before");
|
|
10294
|
+
const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
|
|
10295
|
+
const statusParam = c.req.query("status");
|
|
10296
|
+
const statusFilter = statusParam ? [
|
|
10297
|
+
...new Set(
|
|
10298
|
+
statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
|
|
10299
|
+
)
|
|
10300
|
+
] : [];
|
|
10301
|
+
const supabase = getSupabaseClient();
|
|
10302
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
|
|
10303
|
+
if (before) query = query.lt(sortColumn, before);
|
|
10304
|
+
if (statusFilter.length) {
|
|
10305
|
+
const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
|
|
10306
|
+
const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
|
|
10307
|
+
if (includeNone && withoutNone.length) {
|
|
10308
|
+
query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
|
|
10309
|
+
} else if (includeNone) {
|
|
10310
|
+
query = query.is("board_status", null);
|
|
10311
|
+
} else {
|
|
10312
|
+
query = query.in("board_status", withoutNone);
|
|
10313
|
+
}
|
|
10314
|
+
}
|
|
10315
|
+
const { data: threads, error: error2 } = await query;
|
|
10316
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10317
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10318
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10319
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
|
|
10320
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10321
|
+
});
|
|
10322
|
+
app.get("/channels/:channelId/board/:status", async (c) => {
|
|
10323
|
+
const channelId = c.req.param("channelId");
|
|
10324
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10325
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10326
|
+
if (!column) {
|
|
10327
|
+
return c.json(
|
|
10328
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10329
|
+
400
|
|
10330
|
+
);
|
|
10331
|
+
}
|
|
10332
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10333
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10334
|
+
const before = c.req.query("before");
|
|
10335
|
+
const supabase = getSupabaseClient();
|
|
10336
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId);
|
|
10337
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10338
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10339
|
+
if (before) query = query.lt("updated_at", before);
|
|
10340
|
+
const { data: threads, error: error2 } = await query;
|
|
10341
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10342
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10343
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10344
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10345
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10346
|
+
});
|
|
10347
|
+
app.get("/board/:status", async (c) => {
|
|
10348
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10349
|
+
if (!column) {
|
|
10350
|
+
return c.json(
|
|
10351
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10352
|
+
400
|
|
10353
|
+
);
|
|
10354
|
+
}
|
|
10355
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10356
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10357
|
+
const before = c.req.query("before");
|
|
10358
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10359
|
+
const supabase = getSupabaseClient();
|
|
10360
|
+
let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
|
|
10361
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10362
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10363
|
+
if (before) query = query.lt("updated_at", before);
|
|
10364
|
+
const { data: threads, error: error2 } = await query;
|
|
10365
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10366
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10367
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10368
|
+
const labelled = meta ? withPreview.map((t) => {
|
|
10369
|
+
const channelMeta = meta.get(t.channel_id ?? "");
|
|
10370
|
+
return {
|
|
10371
|
+
...t,
|
|
10372
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10373
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10374
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10375
|
+
};
|
|
10376
|
+
}) : withPreview;
|
|
10377
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10378
|
+
return c.json({ threads: labelled, nextCursor });
|
|
10379
|
+
});
|
|
10380
|
+
app.get("/threads", async (c) => {
|
|
10381
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10382
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10383
|
+
const before = c.req.query("before");
|
|
10384
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10385
|
+
const relevantTo = c.req.query("relevantTo");
|
|
10386
|
+
const supabase = getSupabaseClient();
|
|
10387
|
+
const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
|
|
10388
|
+
let threads;
|
|
10389
|
+
if (isNonEmptyString(relevantTo)) {
|
|
10390
|
+
try {
|
|
10391
|
+
threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
|
|
10392
|
+
} catch (err) {
|
|
10393
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
10394
|
+
}
|
|
10395
|
+
} else {
|
|
10396
|
+
let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10397
|
+
if (before) query = query.lt("updated_at", before);
|
|
10398
|
+
const { data, error: error2 } = await query;
|
|
10399
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10400
|
+
threads = data;
|
|
10401
|
+
}
|
|
10402
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10403
|
+
const threadIds = threads.map((t) => t.id);
|
|
10404
|
+
const chunkResults = await Promise.all(
|
|
10405
|
+
chunk(threadIds, THREAD_CHUNK_SIZE).map(
|
|
10406
|
+
(ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
10407
|
+
)
|
|
10408
|
+
);
|
|
10409
|
+
const repliesByThread = /* @__PURE__ */ new Map();
|
|
10410
|
+
for (const { data } of chunkResults) {
|
|
10411
|
+
for (const r of data ?? []) {
|
|
10412
|
+
const arr = repliesByThread.get(r.thread_id);
|
|
10413
|
+
if (arr) arr.push(r);
|
|
10414
|
+
else repliesByThread.set(r.thread_id, [r]);
|
|
10415
|
+
}
|
|
10416
|
+
}
|
|
10417
|
+
const withReplies = threads.map((t) => {
|
|
10418
|
+
const channelMeta = meta?.get(t.channel_id);
|
|
10419
|
+
return {
|
|
10420
|
+
...t,
|
|
10421
|
+
...meta ? {
|
|
10422
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10423
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10424
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10425
|
+
} : {},
|
|
10426
|
+
replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
|
|
10427
|
+
...r,
|
|
10428
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10429
|
+
}))
|
|
10430
|
+
};
|
|
10431
|
+
});
|
|
10432
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10433
|
+
return c.json({ threads: withReplies, nextCursor });
|
|
10434
|
+
});
|
|
10435
|
+
app.get("/threads/archived", async (c) => {
|
|
10436
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10437
|
+
return c.json({ error: "only an admin can view archived topics" }, 403);
|
|
10438
|
+
}
|
|
10439
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10440
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10441
|
+
const before = c.req.query("before");
|
|
10442
|
+
const supabase = getSupabaseClient();
|
|
10443
|
+
let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
|
|
10444
|
+
if (before) query = query.lt("archived_at", before);
|
|
10445
|
+
const { data: threads, error: error2 } = await query;
|
|
10446
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10447
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10448
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10449
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
|
|
10450
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10451
|
+
});
|
|
10452
|
+
app.get("/threads/:id", async (c) => {
|
|
10453
|
+
const id = c.req.param("id");
|
|
10454
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10455
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
|
|
10456
|
+
const before = c.req.query("before");
|
|
10457
|
+
let repliesQuery = getSupabaseClient().from("thread_replies").select("*, reactions:reply_reactions(emoji, author)").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: false }).limit(limit);
|
|
10458
|
+
if (before) repliesQuery = repliesQuery.lt("created_at", before);
|
|
10459
|
+
const [thread, replies, opening] = await Promise.all([
|
|
10460
|
+
getSupabaseClient().from("threads").select("*").eq("id", id).single(),
|
|
10461
|
+
repliesQuery,
|
|
10462
|
+
// The opening message, fetched separately because the replies page
|
|
10463
|
+
// above is newest-first: on a long thread the opener isn't in it. It
|
|
10464
|
+
// is what the header falls back to on a thread with no title of its
|
|
10465
|
+
// own (see threadCrumbLabel) — a title is never written on the
|
|
10466
|
+
// thread's behalf, so an untitled row must still be able to show and
|
|
10467
|
+
// rename a topic.
|
|
10468
|
+
getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
|
|
10469
|
+
]);
|
|
10470
|
+
if (thread.error) return c.json({ error: thread.error.message }, 404);
|
|
10471
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10472
|
+
const channelId = thread.data?.channel_id;
|
|
10473
|
+
const meta = await globalChannelMeta(c);
|
|
10474
|
+
const channelMeta = channelId ? meta.get(channelId) : void 0;
|
|
10475
|
+
if (!channelId || !channelMeta) {
|
|
10476
|
+
return c.json({ error: "not found" }, 404);
|
|
10477
|
+
}
|
|
10478
|
+
const page2 = replies.data ?? [];
|
|
10479
|
+
const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
|
|
10480
|
+
const formattedReplies = page2.reverse().map((r) => ({
|
|
10481
|
+
...r,
|
|
10482
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10483
|
+
}));
|
|
10484
|
+
const openingBody = (opening.data ?? [])[0]?.body;
|
|
10485
|
+
return c.json({
|
|
10486
|
+
...thread.data,
|
|
10487
|
+
// Always stamped (not just on `?scope=all` feeds, see the `/threads`
|
|
10488
|
+
// route above) — the reply pane can be opened directly by id (global
|
|
10489
|
+
// board, permalink) without the active workspace's channel map ever
|
|
10490
|
+
// having loaded this thread's channel, so the header needs its own
|
|
10491
|
+
// workspace/channel label rather than depending on that cache.
|
|
10492
|
+
workspace: channelMeta.workspace,
|
|
10493
|
+
channel_name: channelMeta.channelName,
|
|
10494
|
+
agent_name: channelMeta.agentName,
|
|
10495
|
+
preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
|
|
10496
|
+
replies: formattedReplies,
|
|
10497
|
+
nextCursor
|
|
10498
|
+
});
|
|
10499
|
+
});
|
|
10500
|
+
app.post("/threads/:id/translation/:lang", async (c) => {
|
|
10501
|
+
const id = c.req.param("id");
|
|
10502
|
+
const lang = canonicalLanguage(c.req.param("lang"));
|
|
10503
|
+
if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
|
|
10504
|
+
const [thread, replies] = await Promise.all([
|
|
10505
|
+
getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
|
|
10506
|
+
getSupabaseClient().from("thread_replies").select("id, author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(TRANSLATABLE_REPLIES_MAX)
|
|
10507
|
+
]);
|
|
10508
|
+
if (thread.error) return c.json({ error: "not found" }, 404);
|
|
10509
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10510
|
+
const channelId = thread.data?.channel_id;
|
|
10511
|
+
if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
|
|
10512
|
+
return c.json({ error: "not found" }, 404);
|
|
10513
|
+
}
|
|
10514
|
+
const messages = (replies.data ?? []).map(
|
|
10515
|
+
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
10516
|
+
);
|
|
10517
|
+
try {
|
|
10518
|
+
return c.json({ lang, messages: await translateThread(id, lang, messages) });
|
|
10519
|
+
} catch (err) {
|
|
10520
|
+
const message = err instanceof Error ? err.message : "translation failed";
|
|
10521
|
+
console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
|
|
10522
|
+
return c.json({ error: message }, 502);
|
|
10523
|
+
}
|
|
10524
|
+
});
|
|
10525
|
+
app.patch("/threads/:id/board-status", async (c) => {
|
|
10526
|
+
const id = c.req.param("id");
|
|
10527
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10528
|
+
const value = input2?.board_status ?? null;
|
|
10529
|
+
if (value !== null && !BOARD_STATUSES.includes(value)) {
|
|
10530
|
+
return c.json(
|
|
10531
|
+
{ error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
|
|
10532
|
+
400
|
|
10533
|
+
);
|
|
10534
|
+
}
|
|
10535
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10536
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
|
|
10537
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10538
|
+
return c.json(thread);
|
|
10539
|
+
});
|
|
10540
|
+
app.patch("/threads/:id", async (c) => {
|
|
10541
|
+
const id = c.req.param("id");
|
|
10542
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10543
|
+
const raw = input2?.title;
|
|
10544
|
+
if (raw !== null && !isNonEmptyString(raw)) {
|
|
10545
|
+
return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
|
|
10546
|
+
}
|
|
10547
|
+
const title = raw === null ? null : raw.trim();
|
|
10548
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10549
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
|
|
10550
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10551
|
+
return c.json(thread);
|
|
10552
|
+
});
|
|
10553
|
+
app.post("/threads/:id/suggest-title", async (c) => {
|
|
10554
|
+
const id = c.req.param("id");
|
|
10555
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10556
|
+
const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
|
|
10557
|
+
if (!thread) return c.json({ error: "not found" }, 404);
|
|
10558
|
+
const { data: rows, error: error2 } = await getSupabaseClient().from("thread_replies").select("author, body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true });
|
|
10559
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10560
|
+
const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10561
|
+
const title = await labelThread(thread.channel_id, transcript, "thread");
|
|
10562
|
+
if (!title) return c.json({ error: "could not generate a topic" }, 502);
|
|
10563
|
+
return c.json({ title });
|
|
10564
|
+
});
|
|
10565
|
+
app.patch("/replies/:replyId", async (c) => {
|
|
10566
|
+
const replyId = c.req.param("replyId");
|
|
10567
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10568
|
+
const author = input2?.author;
|
|
10569
|
+
const body = input2?.body;
|
|
10570
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
|
|
10571
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10572
|
+
}
|
|
10573
|
+
const supabase = getSupabaseClient();
|
|
10574
|
+
const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10575
|
+
if (fetchError) return c.json({ error: fetchError.message }, 404);
|
|
10576
|
+
if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10577
|
+
if (existing.author !== author) {
|
|
10578
|
+
return c.json({ error: "only the author can edit a message" }, 403);
|
|
10579
|
+
}
|
|
10580
|
+
const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
|
|
10581
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10582
|
+
if (isHumanAuthor(author)) {
|
|
10583
|
+
const { data: latest2 } = await supabase.from("thread_replies").select("id").eq("thread_id", existing.thread_id).is("archived_at", null).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
10584
|
+
if (latest2?.id === replyId) {
|
|
10585
|
+
await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
|
|
10586
|
+
});
|
|
10587
|
+
}
|
|
10588
|
+
}
|
|
10589
|
+
return c.json(reply);
|
|
10590
|
+
});
|
|
10591
|
+
app.post("/replies/:replyId/reactions", async (c) => {
|
|
10592
|
+
const replyId = c.req.param("replyId");
|
|
10593
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10594
|
+
const author = input2?.author;
|
|
10595
|
+
const emoji = input2?.emoji;
|
|
10596
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
|
|
10597
|
+
return c.json({ errors: ["author and emoji are required"] }, 400);
|
|
10598
|
+
}
|
|
10599
|
+
const supabase = getSupabaseClient();
|
|
10600
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
|
|
10601
|
+
if (replyError) return c.json({ error: "not found" }, 404);
|
|
10602
|
+
if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10603
|
+
const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
|
|
10604
|
+
if (existingError) return c.json({ error: existingError.message }, 500);
|
|
10605
|
+
if (existing) {
|
|
10606
|
+
const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
|
|
10607
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10608
|
+
} else {
|
|
10609
|
+
const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
|
|
10610
|
+
if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
|
|
10611
|
+
}
|
|
10612
|
+
const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
|
|
10613
|
+
if (reactionsError) return c.json({ error: reactionsError.message }, 500);
|
|
10614
|
+
return c.json({ reactions: reactions ?? [] });
|
|
10615
|
+
});
|
|
10616
|
+
app.patch("/threads/:id/archive", async (c) => {
|
|
10617
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10618
|
+
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10619
|
+
}
|
|
10620
|
+
const id = c.req.param("id");
|
|
10621
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10622
|
+
const archived = input2?.archived;
|
|
10623
|
+
if (typeof archived !== "boolean") {
|
|
10624
|
+
return c.json({ errors: ["archived (boolean) is required"] }, 400);
|
|
10625
|
+
}
|
|
10626
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10627
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
|
|
10628
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10629
|
+
return c.json(thread);
|
|
10630
|
+
});
|
|
10631
|
+
app.post("/channels/:channelId/threads", async (c) => {
|
|
10632
|
+
const channelId = c.req.param("channelId");
|
|
10633
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10634
|
+
const input2 = await c.req.json();
|
|
10635
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10636
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10637
|
+
}
|
|
10638
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10639
|
+
const model = requestedModel(c, input2.model);
|
|
10640
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10641
|
+
const supabase = getSupabaseClient();
|
|
10642
|
+
let thread;
|
|
10643
|
+
let replayed = false;
|
|
10644
|
+
const { data: inserted, error: threadError } = await supabase.from("threads").insert({
|
|
10645
|
+
channel_id: channelId,
|
|
10646
|
+
// A stored title means someone named the topic. A blank one is not a
|
|
10647
|
+
// name, so it lands as null and the topic is derived from the
|
|
10648
|
+
// opening message at display time (see threadCrumbLabel).
|
|
10649
|
+
title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
|
|
10650
|
+
author: input2.author,
|
|
10651
|
+
client_key: clientKey2
|
|
10652
|
+
}).select().single();
|
|
10653
|
+
if (threadError) {
|
|
10654
|
+
if (!clientKey2 || threadError.code !== "23505") {
|
|
10655
|
+
return c.json({ error: threadError.message }, 500);
|
|
10656
|
+
}
|
|
10657
|
+
const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
|
|
10658
|
+
if (!existing) return c.json({ error: threadError.message }, 500);
|
|
10659
|
+
thread = existing;
|
|
10660
|
+
replayed = true;
|
|
10661
|
+
} else {
|
|
10662
|
+
thread = inserted;
|
|
10663
|
+
}
|
|
10664
|
+
let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
|
|
10665
|
+
let replyCompleted = false;
|
|
10666
|
+
if (!reply) {
|
|
10667
|
+
const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
|
|
10668
|
+
thread_id: thread.id,
|
|
10669
|
+
author: input2.author,
|
|
10670
|
+
body: input2.body,
|
|
10671
|
+
client_key: clientKey2,
|
|
10672
|
+
model,
|
|
10673
|
+
effort
|
|
10674
|
+
}).select().single();
|
|
10675
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10676
|
+
reply = insertedReply;
|
|
10677
|
+
replyCompleted = true;
|
|
10678
|
+
}
|
|
10679
|
+
if (!replayed || replyCompleted) {
|
|
10680
|
+
onMessage?.({
|
|
10681
|
+
threadId: thread.id,
|
|
10682
|
+
replyId: reply.id,
|
|
10683
|
+
author: input2.author,
|
|
10684
|
+
body: input2.body,
|
|
10685
|
+
// From the row, not `model`/`effort`: on the replay path the reply
|
|
10686
|
+
// predates this request, so its own stored override is the source of
|
|
10687
|
+
// truth.
|
|
10688
|
+
model: reply["model"] ?? void 0,
|
|
10689
|
+
effort: effortFromRow(reply["effort"])
|
|
10690
|
+
});
|
|
10691
|
+
}
|
|
10692
|
+
if (!replayed && thread["title"] == null) {
|
|
10693
|
+
void generateAutoTitle(thread.id, channelId, input2.body).catch(
|
|
10694
|
+
(err) => console.error("Auto title failed:", err)
|
|
10695
|
+
);
|
|
10696
|
+
}
|
|
10697
|
+
return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
|
|
10419
10698
|
});
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
|
|
10424
|
-
|
|
10425
|
-
|
|
10426
|
-
return (data ?? []).length > 0;
|
|
10427
|
-
}
|
|
10428
|
-
async function listAdminWorkspaces(userId) {
|
|
10429
|
-
const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
|
|
10430
|
-
if (error2) fail("listAdminWorkspaces", error2);
|
|
10431
|
-
return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
10432
|
-
}
|
|
10433
|
-
async function listDeviceWorkspaces(device) {
|
|
10434
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
|
|
10435
|
-
if (error2) fail("listDeviceWorkspaces", error2);
|
|
10436
|
-
const bound = new Set((data ?? []).map((r) => r.workspace_id));
|
|
10437
|
-
return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
|
|
10438
|
-
}
|
|
10439
|
-
async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
|
|
10440
|
-
const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
|
|
10441
|
-
{ device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
|
|
10442
|
-
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
10443
|
-
);
|
|
10444
|
-
if (error2) fail("bindDeviceWorkspace", error2);
|
|
10445
|
-
}
|
|
10446
|
-
async function listWorkspaceDevices(workspaceId) {
|
|
10447
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
|
|
10448
|
-
if (error2) fail("listWorkspaceDevices", error2);
|
|
10449
|
-
return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
10450
|
-
}
|
|
10451
|
-
var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
|
|
10452
|
-
var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
|
|
10453
|
-
async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
|
|
10454
|
-
const hosts2 = /* @__PURE__ */ new Map();
|
|
10455
|
-
if (!workspaceIds.length) return hosts2;
|
|
10456
|
-
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id, devices!inner(name, public_key, revoked_at, last_seen_at)").in("workspace_id", workspaceIds).is("devices.revoked_at", null);
|
|
10457
|
-
if (error2) fail("listWorkspaceHosts", error2);
|
|
10458
|
-
for (const r of data ?? []) {
|
|
10459
|
-
if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
|
|
10460
|
-
hosts2.set(r.workspace_id, r.devices.name);
|
|
10699
|
+
app.post("/replies/:replyId/fork", async (c) => {
|
|
10700
|
+
const replyId = c.req.param("replyId");
|
|
10701
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10702
|
+
const author = input2?.author;
|
|
10703
|
+
if (!isNonEmptyString(author)) {
|
|
10704
|
+
return c.json({ errors: ["author is required"] }, 400);
|
|
10461
10705
|
}
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
}
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
const
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
}
|
|
10706
|
+
const mode = input2?.mode === "focus" ? "focus" : "condense";
|
|
10707
|
+
const supabase = getSupabaseClient();
|
|
10708
|
+
const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10709
|
+
if (sourceError) return c.json({ error: sourceError.message }, 404);
|
|
10710
|
+
const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
|
|
10711
|
+
if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
|
|
10712
|
+
if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
|
|
10713
|
+
return c.json({ error: "not found" }, 404);
|
|
10714
|
+
}
|
|
10715
|
+
const { data: priorReplies } = await supabase.from("thread_replies").select("author, body, created_at").eq("thread_id", source.thread_id).lte("created_at", source.created_at).is("archived_at", null).order("created_at", { ascending: true });
|
|
10716
|
+
const priorRows = priorReplies ?? [];
|
|
10717
|
+
const sourceText = slackMrkdwnToMarkdown(source.body);
|
|
10718
|
+
const FOCUS_CONTEXT_BELOW_CHARS = 280;
|
|
10719
|
+
const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
|
|
10720
|
+
const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10721
|
+
const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
|
|
10722
|
+
const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
|
|
10723
|
+
const openingBody = `${body}
|
|
10481
10724
|
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
"
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
}
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10725
|
+
> \u2442 Forked from [a message by ${source.author}](${backlink})`;
|
|
10726
|
+
const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
|
|
10727
|
+
if (threadError) return c.json({ error: threadError.message }, 500);
|
|
10728
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
|
|
10729
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10730
|
+
onMessage?.({
|
|
10731
|
+
threadId: thread.id,
|
|
10732
|
+
replyId: reply.id,
|
|
10733
|
+
author,
|
|
10734
|
+
body: openingBody
|
|
10735
|
+
});
|
|
10736
|
+
return c.json({ ...thread, replies: [reply] }, 201);
|
|
10737
|
+
});
|
|
10738
|
+
app.post("/threads/:id/replies", async (c) => {
|
|
10739
|
+
const threadId = c.req.param("id");
|
|
10740
|
+
const input2 = await c.req.json();
|
|
10741
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10742
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10743
|
+
}
|
|
10744
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10745
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10746
|
+
if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
|
|
10747
|
+
const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
|
|
10748
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
|
|
10749
|
+
thread_id: threadId,
|
|
10750
|
+
author: input2.author,
|
|
10751
|
+
body: input2.body,
|
|
10752
|
+
client_key: clientKey2,
|
|
10753
|
+
model,
|
|
10754
|
+
effort
|
|
10755
|
+
}).select().single();
|
|
10756
|
+
if (error2) {
|
|
10757
|
+
if (clientKey2 && error2.code === "23505") {
|
|
10758
|
+
const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
|
|
10759
|
+
if (existing) return c.json(existing, 200);
|
|
10760
|
+
}
|
|
10761
|
+
return c.json({ error: error2.message }, 500);
|
|
10762
|
+
}
|
|
10763
|
+
onMessage?.({
|
|
10764
|
+
threadId,
|
|
10765
|
+
replyId: data.id,
|
|
10766
|
+
author: input2.author,
|
|
10767
|
+
body: input2.body,
|
|
10768
|
+
model: data.model ?? void 0,
|
|
10769
|
+
effort: effortFromRow(data.effort)
|
|
10770
|
+
});
|
|
10771
|
+
return c.json(data, 201);
|
|
10772
|
+
});
|
|
10773
|
+
return app;
|
|
10506
10774
|
}
|
|
10507
10775
|
|
|
10508
|
-
// ../api/src/dashboard/
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10776
|
+
// ../api/src/dashboard/doc-shares-routes.ts
|
|
10777
|
+
import { Hono as Hono14 } from "hono";
|
|
10778
|
+
|
|
10779
|
+
// ../api/src/dashboard/doc-share-features.ts
|
|
10780
|
+
import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
|
|
10781
|
+
var DEFAULT_FEATURES = {
|
|
10782
|
+
audioEnabled: false,
|
|
10783
|
+
askEnabled: true,
|
|
10784
|
+
socialEnabled: false,
|
|
10785
|
+
brandingEnabled: true,
|
|
10786
|
+
translationEnabled: false,
|
|
10787
|
+
translationLangs: []
|
|
10788
|
+
};
|
|
10789
|
+
var HTML_FEATURES = {
|
|
10790
|
+
audioEnabled: false,
|
|
10791
|
+
askEnabled: false,
|
|
10792
|
+
socialEnabled: false,
|
|
10793
|
+
brandingEnabled: false,
|
|
10794
|
+
translationEnabled: false,
|
|
10795
|
+
translationLangs: []
|
|
10796
|
+
};
|
|
10797
|
+
function resolveFeatures(requested, existing) {
|
|
10798
|
+
const base = existing ?? DEFAULT_FEATURES;
|
|
10799
|
+
return {
|
|
10800
|
+
audioEnabled: requested?.audio ?? base.audioEnabled,
|
|
10801
|
+
askEnabled: requested?.ask ?? base.askEnabled,
|
|
10802
|
+
socialEnabled: requested?.social ?? base.socialEnabled,
|
|
10803
|
+
brandingEnabled: requested?.branding ?? base.brandingEnabled,
|
|
10804
|
+
translationEnabled: requested?.translation ?? base.translationEnabled,
|
|
10805
|
+
// Replaced wholesale, not merged: the allowlist IS the answer to "which
|
|
10806
|
+
// languages does this link offer", and a merge would make removing one
|
|
10807
|
+
// impossible.
|
|
10808
|
+
translationLangs: requested?.translationLangs ?? base.translationLangs
|
|
10809
|
+
};
|
|
10517
10810
|
}
|
|
10518
|
-
function
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10811
|
+
async function deleteShareAssets(keys) {
|
|
10812
|
+
const client2 = getR2Client();
|
|
10813
|
+
const bucket = getR2Bucket();
|
|
10814
|
+
for (const key of keys) {
|
|
10815
|
+
try {
|
|
10816
|
+
await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
|
|
10817
|
+
} catch {
|
|
10818
|
+
}
|
|
10524
10819
|
}
|
|
10525
10820
|
}
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10821
|
+
async function updateShareFeatures(share, requested) {
|
|
10822
|
+
if (share.kind === "html") return share;
|
|
10823
|
+
if (share.kind === "excalidraw") {
|
|
10824
|
+
if (requested.branding === void 0) return share;
|
|
10825
|
+
return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
|
|
10826
|
+
}
|
|
10827
|
+
const features = resolveFeatures(requested, share);
|
|
10828
|
+
const updated = await updateDocShareFeatures(share.id, features);
|
|
10829
|
+
if (!updated) return null;
|
|
10830
|
+
if (features.audioEnabled) void queueShareAudio(updated);
|
|
10831
|
+
else if (share.audioEnabled) await dropShareAudio(share);
|
|
10832
|
+
return updated;
|
|
10530
10833
|
}
|
|
10531
10834
|
|
|
10532
10835
|
// ../api/src/dashboard/doc-share-publish.ts
|
|
@@ -10537,7 +10840,7 @@ import { CopyObjectCommand, PutObjectCommand as PutObjectCommand2 } from "@aws-s
|
|
|
10537
10840
|
var SHARE_ASSET_PLACEHOLDER = "/share/{{flyspace-share-id}}/assets/";
|
|
10538
10841
|
|
|
10539
10842
|
// ../api/src/dashboard/share-language.ts
|
|
10540
|
-
var
|
|
10843
|
+
var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
|
|
10541
10844
|
var CLASSIFIER_MODEL = "openai/gpt-oss-20b";
|
|
10542
10845
|
var SAMPLE_CHARS = 4e3;
|
|
10543
10846
|
var NOISE = [
|
|
@@ -10576,7 +10879,7 @@ async function detectSourceLanguage(title, content) {
|
|
|
10576
10879
|
const sample = languageSample(title, content);
|
|
10577
10880
|
if (sample.length < MIN_SAMPLE_CHARS) return null;
|
|
10578
10881
|
try {
|
|
10579
|
-
const res = await fetch(
|
|
10882
|
+
const res = await fetch(GROQ_API_URL6, {
|
|
10580
10883
|
method: "POST",
|
|
10581
10884
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10582
10885
|
body: JSON.stringify({
|
|
@@ -10806,7 +11109,7 @@ function resolveChannel2(c) {
|
|
|
10806
11109
|
}
|
|
10807
11110
|
var LIST_LIMIT = 500;
|
|
10808
11111
|
function createDocShareListRouter() {
|
|
10809
|
-
const app = new
|
|
11112
|
+
const app = new Hono14();
|
|
10810
11113
|
app.get("/shares", async (c) => {
|
|
10811
11114
|
const viewer = c.get("auth");
|
|
10812
11115
|
const readable = await filterReadableDocsChannels(
|
|
@@ -10850,7 +11153,7 @@ function createDocShareListRouter() {
|
|
|
10850
11153
|
return app;
|
|
10851
11154
|
}
|
|
10852
11155
|
function createDocShareWriteRouter(agent = {}) {
|
|
10853
|
-
const app = new
|
|
11156
|
+
const app = new Hono14();
|
|
10854
11157
|
app.use("/:id/docs/share", requireDocsAccess);
|
|
10855
11158
|
app.use("/:id/docs/share/*", requireDocsAccess);
|
|
10856
11159
|
app.use("/:id/docs/shares", requireDocsAccess);
|
|
@@ -11023,7 +11326,7 @@ function createDocShareWriteRouter(agent = {}) {
|
|
|
11023
11326
|
}
|
|
11024
11327
|
|
|
11025
11328
|
// ../api/src/dashboard/search-routes.ts
|
|
11026
|
-
import { Hono as
|
|
11329
|
+
import { Hono as Hono15 } from "hono";
|
|
11027
11330
|
function matchScore(q, fields) {
|
|
11028
11331
|
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
11029
11332
|
if (terms.length === 0) return 0;
|
|
@@ -11171,7 +11474,7 @@ function searchAutomations(q) {
|
|
|
11171
11474
|
}
|
|
11172
11475
|
var RESULT_LIMIT = 30;
|
|
11173
11476
|
function createSearchRouter() {
|
|
11174
|
-
const app = new
|
|
11477
|
+
const app = new Hono15();
|
|
11175
11478
|
app.get("/", async (c) => {
|
|
11176
11479
|
const q = c.req.query("q")?.trim();
|
|
11177
11480
|
if (!q) return c.json({ results: [] });
|
|
@@ -11187,14 +11490,14 @@ function createSearchRouter() {
|
|
|
11187
11490
|
}
|
|
11188
11491
|
|
|
11189
11492
|
// ../api/src/dashboard/feature-flags-routes.ts
|
|
11190
|
-
import { Hono as
|
|
11493
|
+
import { Hono as Hono16 } from "hono";
|
|
11191
11494
|
async function isFeatureEnabled(c, flagId) {
|
|
11192
11495
|
const auth = c.get("auth");
|
|
11193
11496
|
const enabled = await listEnabledFeatureFlags(auth.workspaceId);
|
|
11194
11497
|
return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
|
|
11195
11498
|
}
|
|
11196
11499
|
function createFeatureFlagsRouter() {
|
|
11197
|
-
const app = new
|
|
11500
|
+
const app = new Hono16();
|
|
11198
11501
|
app.get("/", async (c) => {
|
|
11199
11502
|
const auth = c.get("auth");
|
|
11200
11503
|
const devHost = isDevHost(c);
|
|
@@ -11221,7 +11524,7 @@ function createFeatureFlagsRouter() {
|
|
|
11221
11524
|
|
|
11222
11525
|
// ../api/src/dashboard/uploads-routes.ts
|
|
11223
11526
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
11224
|
-
import { Hono as
|
|
11527
|
+
import { Hono as Hono17 } from "hono";
|
|
11225
11528
|
import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11226
11529
|
import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
|
|
11227
11530
|
var MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -11236,7 +11539,7 @@ function encodeKeyForUrl(key) {
|
|
|
11236
11539
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
11237
11540
|
}
|
|
11238
11541
|
function createUploadsRouter() {
|
|
11239
|
-
const app = new
|
|
11542
|
+
const app = new Hono17();
|
|
11240
11543
|
app.post("/uploads", async (c) => {
|
|
11241
11544
|
const body = await c.req.parseBody().catch(() => null);
|
|
11242
11545
|
const file = body?.["file"];
|
|
@@ -11281,14 +11584,14 @@ function createUploadsRouter() {
|
|
|
11281
11584
|
}
|
|
11282
11585
|
|
|
11283
11586
|
// ../api/src/dashboard/transcribe-routes.ts
|
|
11284
|
-
import { Hono as
|
|
11587
|
+
import { Hono as Hono18 } from "hono";
|
|
11285
11588
|
var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
|
|
11286
11589
|
var MODEL = "whisper-large-v3-turbo";
|
|
11287
11590
|
var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
|
|
11288
11591
|
var MAX_PROMPT_CHARS = 400;
|
|
11289
11592
|
var NO_SPEECH_THRESHOLD = 0.6;
|
|
11290
11593
|
function createTranscribeRouter() {
|
|
11291
|
-
const app = new
|
|
11594
|
+
const app = new Hono18();
|
|
11292
11595
|
app.post("/transcribe", async (c) => {
|
|
11293
11596
|
if (!await isFeatureEnabled(c, "dictation"))
|
|
11294
11597
|
return c.json({ error: "feature not enabled" }, 403);
|
|
@@ -11328,7 +11631,7 @@ function createTranscribeRouter() {
|
|
|
11328
11631
|
|
|
11329
11632
|
// ../api/src/dashboard/avatars-routes.ts
|
|
11330
11633
|
import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
|
|
11331
|
-
import { Hono as
|
|
11634
|
+
import { Hono as Hono19 } from "hono";
|
|
11332
11635
|
import sharp4 from "sharp";
|
|
11333
11636
|
import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11334
11637
|
var AVATAR_SIZE = 192;
|
|
@@ -11381,7 +11684,7 @@ function avatarKeyPrefix() {
|
|
|
11381
11684
|
return `${KEY_PREFIX2}/${getProfile()}/`;
|
|
11382
11685
|
}
|
|
11383
11686
|
function createAvatarsRouter() {
|
|
11384
|
-
const app = new
|
|
11687
|
+
const app = new Hono19();
|
|
11385
11688
|
app.get("/avatars/slack", async (c) => {
|
|
11386
11689
|
const ctx = await resolveAuthContext(c);
|
|
11387
11690
|
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
@@ -11430,7 +11733,7 @@ function createAvatarsRouter() {
|
|
|
11430
11733
|
}
|
|
11431
11734
|
|
|
11432
11735
|
// ../api/src/dashboard/members-routes.ts
|
|
11433
|
-
import { Hono as
|
|
11736
|
+
import { Hono as Hono20 } from "hono";
|
|
11434
11737
|
function inviteLinkFor(c, token) {
|
|
11435
11738
|
const url = new URL("/auth/invite", callbackUrl(c));
|
|
11436
11739
|
url.searchParams.set("token", token);
|
|
@@ -11438,7 +11741,7 @@ function inviteLinkFor(c, token) {
|
|
|
11438
11741
|
}
|
|
11439
11742
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11440
11743
|
function createMembersRouter() {
|
|
11441
|
-
const members = new
|
|
11744
|
+
const members = new Hono20();
|
|
11442
11745
|
members.get("/", async (c) => {
|
|
11443
11746
|
const ctx = await resolveAuthContext(c);
|
|
11444
11747
|
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
@@ -11665,7 +11968,7 @@ function createMembersRouter() {
|
|
|
11665
11968
|
}
|
|
11666
11969
|
|
|
11667
11970
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11668
|
-
import { Hono as
|
|
11971
|
+
import { Hono as Hono21 } from "hono";
|
|
11669
11972
|
|
|
11670
11973
|
// ../api/src/store/notification-store.ts
|
|
11671
11974
|
function rowToNotification(row) {
|
|
@@ -11733,7 +12036,7 @@ async function markAllRead(channelIds, userId) {
|
|
|
11733
12036
|
|
|
11734
12037
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11735
12038
|
function createNotificationsRouter() {
|
|
11736
|
-
const notifications = new
|
|
12039
|
+
const notifications = new Hono21();
|
|
11737
12040
|
notifications.get("/", async (c) => {
|
|
11738
12041
|
const auth = c.get("auth");
|
|
11739
12042
|
const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
|
|
@@ -11760,7 +12063,7 @@ function createNotificationsRouter() {
|
|
|
11760
12063
|
return notifications;
|
|
11761
12064
|
}
|
|
11762
12065
|
function createThreadReadRouter() {
|
|
11763
|
-
const threads = new
|
|
12066
|
+
const threads = new Hono21();
|
|
11764
12067
|
threads.post("/threads/:threadId/read", async (c) => {
|
|
11765
12068
|
const { userId } = c.get("auth");
|
|
11766
12069
|
const threadId = c.req.param("threadId");
|
|
@@ -11772,7 +12075,7 @@ function createThreadReadRouter() {
|
|
|
11772
12075
|
}
|
|
11773
12076
|
|
|
11774
12077
|
// ../api/src/dashboard/push-routes.ts
|
|
11775
|
-
import { Hono as
|
|
12078
|
+
import { Hono as Hono22 } from "hono";
|
|
11776
12079
|
|
|
11777
12080
|
// ../api/src/push/send.ts
|
|
11778
12081
|
import webpush from "web-push";
|
|
@@ -11875,7 +12178,7 @@ async function pushToUser(userId, payload) {
|
|
|
11875
12178
|
|
|
11876
12179
|
// ../api/src/dashboard/push-routes.ts
|
|
11877
12180
|
function createPushRouter() {
|
|
11878
|
-
const push = new
|
|
12181
|
+
const push = new Hono22();
|
|
11879
12182
|
push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
|
|
11880
12183
|
push.post("/subscribe", async (c) => {
|
|
11881
12184
|
const { userId } = c.get("auth");
|
|
@@ -11906,7 +12209,7 @@ function createPushRouter() {
|
|
|
11906
12209
|
}
|
|
11907
12210
|
|
|
11908
12211
|
// ../api/src/dashboard/mcp-oauth-routes.ts
|
|
11909
|
-
import { Hono as
|
|
12212
|
+
import { Hono as Hono23 } from "hono";
|
|
11910
12213
|
|
|
11911
12214
|
// ../api/src/mcp/crypto.ts
|
|
11912
12215
|
import { createCipheriv, createDecipheriv, randomBytes as randomBytes7, timingSafeEqual } from "crypto";
|
|
@@ -12421,7 +12724,7 @@ function backToDashboard(c, to, params) {
|
|
|
12421
12724
|
return c.redirect(`${target.pathname}${target.search}`);
|
|
12422
12725
|
}
|
|
12423
12726
|
function createMcpOAuthRouter() {
|
|
12424
|
-
const app = new
|
|
12727
|
+
const app = new Hono23();
|
|
12425
12728
|
app.get("/oauth/:provider/start", async (c) => {
|
|
12426
12729
|
const auth = c.get("auth");
|
|
12427
12730
|
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
@@ -12548,7 +12851,7 @@ function createMcpOAuthRouter() {
|
|
|
12548
12851
|
}
|
|
12549
12852
|
|
|
12550
12853
|
// ../api/src/dashboard/mcp-routes.ts
|
|
12551
|
-
import { Hono as
|
|
12854
|
+
import { Hono as Hono24 } from "hono";
|
|
12552
12855
|
|
|
12553
12856
|
// ../api/src/mcp/transport.ts
|
|
12554
12857
|
var KINDS = ["inherit", "npx", "local", "http", "sse"];
|
|
@@ -12678,7 +12981,7 @@ function bindingsByConnection() {
|
|
|
12678
12981
|
return map;
|
|
12679
12982
|
}
|
|
12680
12983
|
function createMcpRouter() {
|
|
12681
|
-
const app = new
|
|
12984
|
+
const app = new Hono24();
|
|
12682
12985
|
app.get("/providers", async (c) => {
|
|
12683
12986
|
const denied = requireAdmin(c.get("auth"));
|
|
12684
12987
|
if (denied) return c.json({ error: denied }, 403);
|
|
@@ -13060,7 +13363,7 @@ async function buildThreadUsage(threadTs) {
|
|
|
13060
13363
|
}
|
|
13061
13364
|
|
|
13062
13365
|
// ../api/src/dashboard/channel-members-routes.ts
|
|
13063
|
-
import { Hono as
|
|
13366
|
+
import { Hono as Hono25 } from "hono";
|
|
13064
13367
|
async function isChannelAdmin(ctx, channelId) {
|
|
13065
13368
|
if (ctx.role === "admin") return true;
|
|
13066
13369
|
const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
|
|
@@ -13069,7 +13372,7 @@ async function isChannelAdmin(ctx, channelId) {
|
|
|
13069
13372
|
return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
|
|
13070
13373
|
}
|
|
13071
13374
|
function createChannelMembersRouter() {
|
|
13072
|
-
const router = new
|
|
13375
|
+
const router = new Hono25();
|
|
13073
13376
|
router.get("/:id/members", async (c) => {
|
|
13074
13377
|
const ctx = await resolveAuthContext(c);
|
|
13075
13378
|
if (!ctx) return c.json({ error: "workspace access required" }, 403);
|
|
@@ -13139,7 +13442,7 @@ function createChannelMembersRouter() {
|
|
|
13139
13442
|
}
|
|
13140
13443
|
|
|
13141
13444
|
// ../api/src/dashboard/memories-routes.ts
|
|
13142
|
-
import { Hono as
|
|
13445
|
+
import { Hono as Hono26 } from "hono";
|
|
13143
13446
|
async function readPatch(c) {
|
|
13144
13447
|
const body = await c.req.json().catch(() => ({}));
|
|
13145
13448
|
const patch = {};
|
|
@@ -13167,7 +13470,7 @@ var requireAgentMemory = async (c, next) => {
|
|
|
13167
13470
|
await next();
|
|
13168
13471
|
};
|
|
13169
13472
|
function createMemoriesRouter() {
|
|
13170
|
-
const router = new
|
|
13473
|
+
const router = new Hono26();
|
|
13171
13474
|
for (const path of [
|
|
13172
13475
|
"/:id/memories",
|
|
13173
13476
|
"/:id/memories/*",
|
|
@@ -13240,7 +13543,7 @@ function createMemoriesRouter() {
|
|
|
13240
13543
|
return router;
|
|
13241
13544
|
}
|
|
13242
13545
|
function createScopedMemoriesRouter() {
|
|
13243
|
-
const router = new
|
|
13546
|
+
const router = new Hono26();
|
|
13244
13547
|
router.use("*", requireAgentMemory);
|
|
13245
13548
|
const workspace = (c) => ({
|
|
13246
13549
|
scope: "workspace",
|
|
@@ -13299,218 +13602,6 @@ function createScopedMemoriesRouter() {
|
|
|
13299
13602
|
return router;
|
|
13300
13603
|
}
|
|
13301
13604
|
|
|
13302
|
-
// ../api/src/dashboard/onboarding-routes.ts
|
|
13303
|
-
import { Hono as Hono26 } from "hono";
|
|
13304
|
-
|
|
13305
|
-
// ../api/src/dashboard/unique-channel-name.ts
|
|
13306
|
-
function uniqueChannelName(raw, taken) {
|
|
13307
|
-
const base = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
13308
|
-
let name = base;
|
|
13309
|
-
for (let i = 2; taken.has(name); i += 1) name = `${base}-${i}`;
|
|
13310
|
-
taken.add(name);
|
|
13311
|
-
return name;
|
|
13312
|
-
}
|
|
13313
|
-
|
|
13314
|
-
// ../api/src/dashboard/workspace-greeting.ts
|
|
13315
|
-
var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
|
|
13316
|
-
var GREETING_MODEL = "openai/gpt-oss-20b";
|
|
13317
|
-
var TIMEOUT_MS = 6e3;
|
|
13318
|
-
var MAX_LISTED_CHANNELS = 8;
|
|
13319
|
-
var GUIDES = [
|
|
13320
|
-
"Welcome to Flyspace",
|
|
13321
|
-
"Working with the agent",
|
|
13322
|
-
"Channels and folders",
|
|
13323
|
-
"Docs",
|
|
13324
|
-
"Sharing docs",
|
|
13325
|
-
"Inviting your team",
|
|
13326
|
-
"Automations"
|
|
13327
|
-
];
|
|
13328
|
-
var SYSTEM_PROMPT = `You write the first message a new Flyspace workspace owner sees, posted in their #general channel. Flyspace is a team chat where an AI agent works inside every channel.
|
|
13329
|
-
|
|
13330
|
-
- Plain Markdown, 60 to 110 words, in English. No headings, no emoji, no em or en dashes, no sign-off.
|
|
13331
|
-
- Greet the owner by name and name the workspace.
|
|
13332
|
-
- Say that #general is public: everyone invited to the workspace can see it.
|
|
13333
|
-
- If project channels are listed, mention them as #name and say each one is linked to that project folder. Never invent a channel.
|
|
13334
|
-
- Mention the trial or the missing Host only if the facts say so.
|
|
13335
|
-
- If guides are listed, say they are in the Docs pane of #general and suggest starting with the first one.
|
|
13336
|
-
- Use only the facts given. Do not add buttons or a list of next steps: those are appended for you.`;
|
|
13337
|
-
function greetingButtons(input2) {
|
|
13338
|
-
const buttons = [];
|
|
13339
|
-
if (!input2.hostConnected) buttons.push("Connect a machine");
|
|
13340
|
-
if (input2.channels[0]) buttons.push(`Summarise #${input2.channels[0]} for me`);
|
|
13341
|
-
if (input2.hostConnected) buttons.push("Give me a quick tour of the guides");
|
|
13342
|
-
if (input2.memberCount <= 1) buttons.push("Invite my team");
|
|
13343
|
-
buttons.push("Set up a daily digest");
|
|
13344
|
-
return buttons.slice(0, 3).map((b) => b.replace(/[|\]]/g, ""));
|
|
13345
|
-
}
|
|
13346
|
-
function fallbackGreeting(input2) {
|
|
13347
|
-
const channels = input2.channels.slice(0, MAX_LISTED_CHANNELS).map((c) => `#${c}`);
|
|
13348
|
-
return [
|
|
13349
|
-
`Welcome to ${input2.workspaceName}, ${input2.ownerName}.`,
|
|
13350
|
-
"This is #general. It is public, so everyone you invite to the workspace can see it.",
|
|
13351
|
-
channels.length ? `Your project channels are ready: ${channels.join(", ")}. Each one is linked to its project folder, so the agent there works on the real files.` : "",
|
|
13352
|
-
input2.hostConnected ? "A few short guides are waiting in the Docs pane of #general. Start with Welcome to Flyspace." : "",
|
|
13353
|
-
"Mention the agent in any channel to get started."
|
|
13354
|
-
].filter(Boolean).join("\n\n");
|
|
13355
|
-
}
|
|
13356
|
-
async function groqGreeting(input2, apiKey) {
|
|
13357
|
-
const facts = [
|
|
13358
|
-
`Owner: ${input2.ownerName}`,
|
|
13359
|
-
`Workspace: ${input2.workspaceName}`,
|
|
13360
|
-
`Plan: ${input2.trialing ? "14-day free trial, just started" : "not on a trial"}`,
|
|
13361
|
-
`Host: ${input2.hostConnected ? "a machine is connected, agents can run" : "no machine connected yet, agents cannot run until one is"}`,
|
|
13362
|
-
`Project channels: ${input2.channels.slice(0, MAX_LISTED_CHANNELS).join(", ") || "(none yet)"}`,
|
|
13363
|
-
`Members: ${input2.memberCount}`,
|
|
13364
|
-
`Guides in #general: ${input2.hostConnected ? GUIDES.join(", ") : "(none, no machine connected)"}`
|
|
13365
|
-
].join("\n");
|
|
13366
|
-
const res = await fetch(GROQ_API_URL6, {
|
|
13367
|
-
method: "POST",
|
|
13368
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
13369
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
13370
|
-
body: JSON.stringify({
|
|
13371
|
-
model: GREETING_MODEL,
|
|
13372
|
-
temperature: 0.6,
|
|
13373
|
-
// Reasoning model: the cap covers reasoning tokens too, so keep it roomy.
|
|
13374
|
-
reasoning_effort: "low",
|
|
13375
|
-
max_tokens: 900,
|
|
13376
|
-
messages: [
|
|
13377
|
-
{ role: "system", content: SYSTEM_PROMPT },
|
|
13378
|
-
{ role: "user", content: facts }
|
|
13379
|
-
]
|
|
13380
|
-
})
|
|
13381
|
-
});
|
|
13382
|
-
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
13383
|
-
const data = await res.json();
|
|
13384
|
-
const text = data.choices?.[0]?.message?.content?.replace(/\[BUTTONS:[^\]]*\]/g, "").replace(/\s*[–—]\s*/g, ", ").trim();
|
|
13385
|
-
return text || null;
|
|
13386
|
-
}
|
|
13387
|
-
async function buildGreeting(input2) {
|
|
13388
|
-
let text = null;
|
|
13389
|
-
const apiKey = process.env["GROQ_API_KEY"];
|
|
13390
|
-
if (apiKey) {
|
|
13391
|
-
try {
|
|
13392
|
-
text = await groqGreeting(input2, apiKey);
|
|
13393
|
-
} catch (err) {
|
|
13394
|
-
console.error("Workspace greeting failed, using the fixed template:", err);
|
|
13395
|
-
}
|
|
13396
|
-
}
|
|
13397
|
-
return `${text ?? fallbackGreeting(input2)}
|
|
13398
|
-
|
|
13399
|
-
[BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
|
|
13400
|
-
}
|
|
13401
|
-
|
|
13402
|
-
// ../api/src/dashboard/onboarding-routes.ts
|
|
13403
|
-
var MAX_FOLDERS = 20;
|
|
13404
|
-
function hostUnavailable(c, agent, method) {
|
|
13405
|
-
return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
|
|
13406
|
-
}
|
|
13407
|
-
function createOnboardingRouter(agent) {
|
|
13408
|
-
const app = new Hono26();
|
|
13409
|
-
app.get("/scan", async (c) => {
|
|
13410
|
-
const ctx = await resolveAuthContext(c);
|
|
13411
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13412
|
-
const scan = await agent.scanOnboarding?.();
|
|
13413
|
-
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
13414
|
-
return c.json(scan);
|
|
13415
|
-
});
|
|
13416
|
-
app.post("/channels", async (c) => {
|
|
13417
|
-
const ctx = await resolveAuthContext(c);
|
|
13418
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13419
|
-
const body = await c.req.json().catch(() => ({}));
|
|
13420
|
-
const runner = body["agent"];
|
|
13421
|
-
const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
13422
|
-
if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
|
|
13423
|
-
const scan = await agent.scanOnboarding?.();
|
|
13424
|
-
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
13425
|
-
const offered = new Map(scan.projects.map((p) => [p.path, p]));
|
|
13426
|
-
const unknown = folders.find((f) => !offered.has(f));
|
|
13427
|
-
if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
13428
|
-
const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
|
|
13429
|
-
taken.add("general");
|
|
13430
|
-
const items = [{ name: "general" }];
|
|
13431
|
-
for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
|
|
13432
|
-
const linked = await agent.linkProjectFolders?.(getProfile(), items);
|
|
13433
|
-
if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
|
|
13434
|
-
if (runner === "claude" || runner === "codex") {
|
|
13435
|
-
await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
|
|
13436
|
-
}
|
|
13437
|
-
const created = [];
|
|
13438
|
-
for (const l of linked) {
|
|
13439
|
-
created.push(
|
|
13440
|
-
l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
|
|
13441
|
-
);
|
|
13442
|
-
}
|
|
13443
|
-
await markWorkspaceProjectsSetup(ctx.workspaceId);
|
|
13444
|
-
return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
|
|
13445
|
-
});
|
|
13446
|
-
app.post("/reset", async (c) => {
|
|
13447
|
-
const ctx = await resolveAuthContext(c);
|
|
13448
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13449
|
-
const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
|
|
13450
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
13451
|
-
await resetWorkspaceOnboarding(ctx.workspaceId);
|
|
13452
|
-
return c.json({ ok: true });
|
|
13453
|
-
});
|
|
13454
|
-
app.get("/greeting", async (c) => {
|
|
13455
|
-
const ctx = await resolveAuthContext(c);
|
|
13456
|
-
if (ctx?.role !== "admin") return c.json({ pending: false });
|
|
13457
|
-
const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
|
|
13458
|
-
const pending2 = !!ctx.projectsSetupAt && !greetedAt;
|
|
13459
|
-
if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
|
|
13460
|
-
const general = await ensureGeneralChannel();
|
|
13461
|
-
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
|
|
13462
|
-
return c.json({ pending: pending2, channelId: general.id, threadId });
|
|
13463
|
-
});
|
|
13464
|
-
app.post("/greeting", async (c) => {
|
|
13465
|
-
const ctx = await resolveAuthContext(c);
|
|
13466
|
-
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
13467
|
-
const supabase = getSupabaseClient();
|
|
13468
|
-
const general = await ensureGeneralChannel();
|
|
13469
|
-
const ownerName = ctx.name?.trim() || ctx.handle;
|
|
13470
|
-
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
|
|
13471
|
-
if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
|
|
13472
|
-
const done = { channelId: general.id, threadId };
|
|
13473
|
-
const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
13474
|
-
if (replied.data) return c.json(done);
|
|
13475
|
-
if (await getWorkspaceGreetedAt(ctx.workspaceId))
|
|
13476
|
-
return c.json({ error: "already greeted" }, 409);
|
|
13477
|
-
const [branding, billing, members] = await Promise.all([
|
|
13478
|
-
getWorkspaceBranding(getProfile()).catch(() => null),
|
|
13479
|
-
getWorkspaceBilling(ctx.workspaceId).catch(() => null),
|
|
13480
|
-
listMemberships(ctx.workspaceId).catch(() => [])
|
|
13481
|
-
]);
|
|
13482
|
-
const body = await buildGreeting({
|
|
13483
|
-
ownerName,
|
|
13484
|
-
workspaceName: branding?.displayName || getProfile(),
|
|
13485
|
-
trialing: billing?.planStatus === "trialing",
|
|
13486
|
-
hostConnected: !!ctx.agentConnectedAt,
|
|
13487
|
-
channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
|
|
13488
|
-
memberCount: members.filter((m) => !m.isSupport).length
|
|
13489
|
-
});
|
|
13490
|
-
const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
13491
|
-
if (again.data) return c.json(done);
|
|
13492
|
-
const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
|
|
13493
|
-
if (reply.error) return c.json({ error: reply.error.message }, 500);
|
|
13494
|
-
await markWorkspaceGreeted(ctx.workspaceId);
|
|
13495
|
-
return c.json(done, 201);
|
|
13496
|
-
});
|
|
13497
|
-
return app;
|
|
13498
|
-
}
|
|
13499
|
-
async function ensureGreetingThread(workspaceId, channelId, ownerName) {
|
|
13500
|
-
const supabase = getSupabaseClient();
|
|
13501
|
-
const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
|
|
13502
|
-
const found = await existing();
|
|
13503
|
-
if (found.data) return found.data.id;
|
|
13504
|
-
const { data, error: error2 } = await supabase.from("threads").insert({
|
|
13505
|
-
channel_id: channelId,
|
|
13506
|
-
title: `Welcome to your Flyspace, ${ownerName}!`,
|
|
13507
|
-
author: AGENT_AUTHOR,
|
|
13508
|
-
client_key: workspaceId
|
|
13509
|
-
}).select("id").single();
|
|
13510
|
-
if (data) return data.id;
|
|
13511
|
-
return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
|
|
13512
|
-
}
|
|
13513
|
-
|
|
13514
13605
|
// ../api/src/dashboard/host-status-signal.ts
|
|
13515
13606
|
var HOST_STATUS_CHANNEL = "host-status";
|
|
13516
13607
|
var channelReady = null;
|
|
@@ -14671,7 +14762,7 @@ import {
|
|
|
14671
14762
|
readSync as readSync2,
|
|
14672
14763
|
readdirSync as readdirSync5,
|
|
14673
14764
|
readFileSync as readFileSync20,
|
|
14674
|
-
realpathSync as
|
|
14765
|
+
realpathSync as realpathSync4,
|
|
14675
14766
|
renameSync,
|
|
14676
14767
|
rmSync as rmSync8,
|
|
14677
14768
|
statSync as statSync6,
|
|
@@ -17514,7 +17605,7 @@ function moveDocEntry(root, from, to) {
|
|
|
17514
17605
|
return { status: "into-self" };
|
|
17515
17606
|
}
|
|
17516
17607
|
try {
|
|
17517
|
-
if (
|
|
17608
|
+
if (realpathSync4(finalAbs) !== realpathSync4(absFrom)) return { status: "exists" };
|
|
17518
17609
|
} catch {
|
|
17519
17610
|
}
|
|
17520
17611
|
renameSync(absFrom, finalAbs);
|
|
@@ -18781,7 +18872,7 @@ function localAgentCallbacks(chatAgent) {
|
|
|
18781
18872
|
docsRequest: (req) => handleDocsRequest(req),
|
|
18782
18873
|
hostRequest: (req) => handleHostRequest(req),
|
|
18783
18874
|
listSessions: () => listLocalSessions(),
|
|
18784
|
-
scanOnboarding: () => scanOnboarding(),
|
|
18875
|
+
scanOnboarding: (slug) => scanOnboarding(slug),
|
|
18785
18876
|
linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
|
|
18786
18877
|
isHostOnline: () => true
|
|
18787
18878
|
};
|
|
@@ -19654,7 +19745,7 @@ async function main() {
|
|
|
19654
19745
|
break;
|
|
19655
19746
|
case "-v":
|
|
19656
19747
|
case "--version":
|
|
19657
|
-
printRaw("0.1.
|
|
19748
|
+
printRaw("0.1.29");
|
|
19658
19749
|
break;
|
|
19659
19750
|
case "-h":
|
|
19660
19751
|
case "--help":
|