flyspace-cli 0.1.27 → 0.1.30
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 +2066 -1935
- package/package.json +7 -2
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.30" : 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)];
|
|
@@ -2058,11 +2108,11 @@ async function serverReachable() {
|
|
|
2058
2108
|
return err.cause ? String(err.cause) : err.message;
|
|
2059
2109
|
}
|
|
2060
2110
|
}
|
|
2061
|
-
function checkService(name, installed, file,
|
|
2111
|
+
function checkService(name, installed, file, running2, fix) {
|
|
2062
2112
|
if (!installed) bad2(`${name} not installed`, `run: ${fix}`);
|
|
2063
2113
|
else if (serviceBinaryMissing(file))
|
|
2064
2114
|
bad2(`${name} points at a CLI that no longer exists`, `run: ${fix}`);
|
|
2065
|
-
else if (!
|
|
2115
|
+
else if (!running2) bad2(`${name} not running`, `logs: ${tilde3(paths.LOG_DIR)}`);
|
|
2066
2116
|
else ok2(`${name} running`);
|
|
2067
2117
|
}
|
|
2068
2118
|
async function runDoctor(args) {
|
|
@@ -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.30";
|
|
2372
2422
|
console.log(`Current version: ${current}`);
|
|
2373
2423
|
console.log("Checking for updates...\n");
|
|
2374
2424
|
try {
|
|
@@ -6298,10 +6348,10 @@ async function runMcpCmd(args) {
|
|
|
6298
6348
|
}
|
|
6299
6349
|
|
|
6300
6350
|
// src/commands/dashboard.ts
|
|
6301
|
-
import { spawn as
|
|
6351
|
+
import { spawn as spawn8, spawnSync } from "child_process";
|
|
6302
6352
|
import { existsSync as existsSync32 } from "fs";
|
|
6303
6353
|
import { platform as platform7 } from "os";
|
|
6304
|
-
import { join as
|
|
6354
|
+
import { join as join29 } from "path";
|
|
6305
6355
|
|
|
6306
6356
|
// src/reconcile.ts
|
|
6307
6357
|
import { existsSync as existsSync14, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
|
|
@@ -7348,8 +7398,8 @@ var inFlight = /* @__PURE__ */ new Map();
|
|
|
7348
7398
|
async function readOrCreateTranslation(input2) {
|
|
7349
7399
|
const contentHash = translationHash(input2.title, input2.content);
|
|
7350
7400
|
const key = `${input2.shareId}:${contentHash}:${input2.lang}`;
|
|
7351
|
-
const
|
|
7352
|
-
if (
|
|
7401
|
+
const running2 = inFlight.get(key);
|
|
7402
|
+
if (running2) return running2;
|
|
7353
7403
|
const job = (async () => {
|
|
7354
7404
|
const cached = await getDocShareTranslation(input2.shareId, input2.lang, contentHash);
|
|
7355
7405
|
if (cached) return cached;
|
|
@@ -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,2036 @@ async function verifyTurnstileToken(token) {
|
|
|
8752
8805
|
return data.action === void 0 || data.action === "login";
|
|
8753
8806
|
}
|
|
8754
8807
|
|
|
8755
|
-
// ../api/src/dashboard/
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8808
|
+
// ../api/src/dashboard/onboarding-routes.ts
|
|
8809
|
+
import { Hono as Hono7 } from "hono";
|
|
8810
|
+
|
|
8811
|
+
// ../api/src/store/device-store.ts
|
|
8812
|
+
import { createHash as createHash7, createPublicKey as createPublicKey2, randomBytes as randomBytes4, randomInt } from "crypto";
|
|
8813
|
+
var DEVICE_CODE_TTL_MS = 10 * 6e4;
|
|
8814
|
+
var ACCESS_TOKEN_TTL_MS = 15 * 6e4;
|
|
8815
|
+
var CHALLENGE_TTL_MS = 2 * 6e4;
|
|
8816
|
+
var POLL_INTERVAL_S = 5;
|
|
8817
|
+
var sha256 = (s) => createHash7("sha256").update(s).digest("hex");
|
|
8818
|
+
function parseEd25519PublicKey(b64) {
|
|
8819
|
+
if (typeof b64 !== "string" || b64.length > 200) return null;
|
|
8820
|
+
try {
|
|
8821
|
+
const key = createPublicKey2({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
8822
|
+
return key.asymmetricKeyType === "ed25519" ? key : null;
|
|
8823
|
+
} catch {
|
|
8824
|
+
return null;
|
|
8825
|
+
}
|
|
8826
|
+
}
|
|
8827
|
+
var USER_CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ";
|
|
8828
|
+
function generateUserCode() {
|
|
8829
|
+
const pick = () => Array.from({ length: 4 }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
|
|
8830
|
+
return `${pick()}-${pick()}`;
|
|
8831
|
+
}
|
|
8832
|
+
function normalizeUserCode(input2) {
|
|
8833
|
+
const raw = input2.toUpperCase().replace(/[\s-]/g, "");
|
|
8834
|
+
if (raw.length !== 8 || [...raw].some((ch) => !USER_CODE_ALPHABET.includes(ch))) return null;
|
|
8835
|
+
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
|
8836
|
+
}
|
|
8837
|
+
function toDevice(r) {
|
|
8838
|
+
return {
|
|
8839
|
+
id: String(r["id"]),
|
|
8840
|
+
userId: String(r["user_id"]),
|
|
8841
|
+
name: String(r["name"]),
|
|
8842
|
+
platform: r["platform"] ?? null,
|
|
8843
|
+
publicKey: String(r["public_key"]),
|
|
8844
|
+
createdAt: String(r["created_at"]),
|
|
8845
|
+
lastSeenAt: r["last_seen_at"] ?? null,
|
|
8846
|
+
revokedAt: r["revoked_at"] ?? null
|
|
8777
8847
|
};
|
|
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
8848
|
}
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8849
|
+
function toPending(r) {
|
|
8850
|
+
return {
|
|
8851
|
+
id: String(r["id"]),
|
|
8852
|
+
userCode: String(r["user_code"]),
|
|
8853
|
+
publicKey: String(r["public_key"]),
|
|
8854
|
+
name: String(r["name"]),
|
|
8855
|
+
platform: r["platform"] ?? null,
|
|
8856
|
+
requesterIp: r["requester_ip"] ?? null,
|
|
8857
|
+
approvedBy: r["approved_by"] ?? null,
|
|
8858
|
+
workspaceIds: r["workspace_ids"] ?? [],
|
|
8859
|
+
deniedAt: r["denied_at"] ?? null,
|
|
8860
|
+
lastPolledAt: r["last_polled_at"] ?? null,
|
|
8861
|
+
expiresAt: String(r["expires_at"]),
|
|
8862
|
+
createdAt: String(r["created_at"])
|
|
8863
|
+
};
|
|
8836
8864
|
}
|
|
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();
|
|
8865
|
+
function fail(what, error2) {
|
|
8866
|
+
throw new Error(`${what}: ${error2.message}`);
|
|
8847
8867
|
}
|
|
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
|
-
}
|
|
8868
|
+
async function createDeviceCode(input2) {
|
|
8869
|
+
const db = getSupabaseClient();
|
|
8870
|
+
await db.from("device_codes").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8871
|
+
const deviceCode = randomBytes4(32).toString("hex");
|
|
8872
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8873
|
+
const userCode = generateUserCode();
|
|
8874
|
+
const { error: error2 } = await db.from("device_codes").insert({
|
|
8875
|
+
device_code_hash: sha256(deviceCode),
|
|
8876
|
+
user_code: userCode,
|
|
8877
|
+
public_key: input2.publicKey,
|
|
8878
|
+
name: input2.name,
|
|
8879
|
+
platform: input2.platform,
|
|
8880
|
+
requester_ip: input2.requesterIp,
|
|
8881
|
+
expires_at: new Date(Date.now() + DEVICE_CODE_TTL_MS).toISOString()
|
|
8897
8882
|
});
|
|
8898
|
-
if (!
|
|
8899
|
-
|
|
8900
|
-
}
|
|
8901
|
-
|
|
8883
|
+
if (!error2) return { deviceCode, userCode };
|
|
8884
|
+
if (error2.code !== "23505") fail("createDeviceCode", error2);
|
|
8885
|
+
}
|
|
8886
|
+
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
8902
8887
|
}
|
|
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
|
-
);
|
|
8888
|
+
async function getDeviceCodeBySecret(deviceCode) {
|
|
8889
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("device_code_hash", sha256(deviceCode)).maybeSingle();
|
|
8890
|
+
if (error2) fail("getDeviceCodeBySecret", error2);
|
|
8891
|
+
return data ? toPending(data) : null;
|
|
8913
8892
|
}
|
|
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
|
-
})
|
|
8893
|
+
async function getDeviceCodeByUserCode(userCode) {
|
|
8894
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").select("*").eq("user_code", userCode).maybeSingle();
|
|
8895
|
+
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
8896
|
+
return data ? toPending(data) : null;
|
|
8897
|
+
}
|
|
8898
|
+
async function markPolled(id) {
|
|
8899
|
+
const { error: error2 } = await getSupabaseClient().from("device_codes").update({ last_polled_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id);
|
|
8900
|
+
if (error2) fail("markPolled", error2);
|
|
8901
|
+
}
|
|
8902
|
+
async function decideDeviceCode(id, userId, decision) {
|
|
8903
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_codes").update(
|
|
8904
|
+
decision.approve ? { approved_by: userId, workspace_ids: decision.workspaceIds } : { denied_at: (/* @__PURE__ */ new Date()).toISOString() }
|
|
8905
|
+
).eq("id", id).is("approved_by", null).is("denied_at", null).select("id");
|
|
8906
|
+
if (error2) fail("decideDeviceCode", error2);
|
|
8907
|
+
return (data ?? []).length > 0;
|
|
8908
|
+
}
|
|
8909
|
+
async function redeemDeviceCode(pending2) {
|
|
8910
|
+
const db = getSupabaseClient();
|
|
8911
|
+
const { data: claimed, error: claimError } = await db.from("device_codes").delete().eq("id", pending2.id).select("id");
|
|
8912
|
+
if (claimError) fail("redeemDeviceCode", claimError);
|
|
8913
|
+
if (!claimed?.length) return null;
|
|
8914
|
+
const existing = pending2.approvedBy ? await findDeviceByKey(pending2.publicKey, pending2.approvedBy) : null;
|
|
8915
|
+
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({
|
|
8916
|
+
user_id: pending2.approvedBy,
|
|
8917
|
+
name: pending2.name,
|
|
8918
|
+
platform: pending2.platform,
|
|
8919
|
+
public_key: pending2.publicKey
|
|
8920
|
+
}).select("*").single();
|
|
8921
|
+
if (error2) fail("redeemDeviceCode", error2);
|
|
8922
|
+
const device = toDevice(data);
|
|
8923
|
+
if (pending2.workspaceIds.length) {
|
|
8924
|
+
const { error: bindError } = await db.from("device_workspaces").upsert(
|
|
8925
|
+
pending2.workspaceIds.map((workspaceId) => ({
|
|
8926
|
+
device_id: device.id,
|
|
8927
|
+
workspace_id: workspaceId,
|
|
8928
|
+
bound_by: pending2.approvedBy
|
|
8929
|
+
})),
|
|
8930
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8951
8931
|
);
|
|
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;
|
|
8932
|
+
if (bindError) fail("redeemDeviceCode", bindError);
|
|
8933
|
+
}
|
|
8934
|
+
return device;
|
|
9037
8935
|
}
|
|
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);
|
|
8936
|
+
async function getDevice(id) {
|
|
8937
|
+
const { data, error: error2 } = await getSupabaseClient().from("devices").select("*").eq("id", id).maybeSingle();
|
|
8938
|
+
if (error2) fail("getDevice", error2);
|
|
8939
|
+
return data ? toDevice(data) : null;
|
|
9052
8940
|
}
|
|
9053
|
-
function
|
|
9054
|
-
const {
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
return rest;
|
|
8941
|
+
async function findDeviceByKey(publicKey, userId) {
|
|
8942
|
+
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();
|
|
8943
|
+
if (error2) fail("findDeviceByKey", error2);
|
|
8944
|
+
return data ? toDevice(data) : null;
|
|
9058
8945
|
}
|
|
9059
|
-
function
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
8946
|
+
async function issueAccessToken(deviceId) {
|
|
8947
|
+
const token = `fsd_${randomBytes4(32).toString("hex")}`;
|
|
8948
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({
|
|
8949
|
+
access_token_hash: sha256(token),
|
|
8950
|
+
access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
8951
|
+
last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
8952
|
+
}).eq("id", deviceId);
|
|
8953
|
+
if (error2) fail("issueAccessToken", error2);
|
|
8954
|
+
return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
|
|
9063
8955
|
}
|
|
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;
|
|
8956
|
+
async function deviceForAccessToken(token) {
|
|
8957
|
+
if (!token.startsWith("fsd_")) return null;
|
|
8958
|
+
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();
|
|
8959
|
+
if (error2) fail("deviceForAccessToken", error2);
|
|
8960
|
+
return data ? toDevice(data) : null;
|
|
9118
8961
|
}
|
|
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 });
|
|
8962
|
+
async function revokeDevice2(id) {
|
|
8963
|
+
const { error: error2 } = await getSupabaseClient().from("devices").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString(), access_token_hash: null }).eq("id", id);
|
|
8964
|
+
if (error2) fail("revokeDevice", error2);
|
|
8965
|
+
}
|
|
8966
|
+
async function createChallenge(deviceId) {
|
|
8967
|
+
const db = getSupabaseClient();
|
|
8968
|
+
await db.from("device_challenges").delete().lt("expires_at", (/* @__PURE__ */ new Date()).toISOString());
|
|
8969
|
+
const nonce = randomBytes4(32).toString("base64url");
|
|
8970
|
+
const { error: error2 } = await db.from("device_challenges").insert({
|
|
8971
|
+
nonce,
|
|
8972
|
+
device_id: deviceId,
|
|
8973
|
+
expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
|
|
9172
8974
|
});
|
|
9173
|
-
|
|
8975
|
+
if (error2) fail("createChallenge", error2);
|
|
8976
|
+
return nonce;
|
|
9174
8977
|
}
|
|
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;
|
|
8978
|
+
async function consumeChallenge(deviceId, nonce) {
|
|
8979
|
+
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");
|
|
8980
|
+
if (error2) fail("consumeChallenge", error2);
|
|
8981
|
+
return (data ?? []).length > 0;
|
|
9211
8982
|
}
|
|
9212
|
-
function
|
|
9213
|
-
const
|
|
9214
|
-
|
|
8983
|
+
async function listAdminWorkspaces(userId) {
|
|
8984
|
+
const { data, error: error2 } = await getSupabaseClient().from("memberships").select("workspace_id, workspaces!inner(slug)").eq("user_id", userId).eq("role", "admin").eq("status", "active");
|
|
8985
|
+
if (error2) fail("listAdminWorkspaces", error2);
|
|
8986
|
+
return (data ?? []).map((r) => ({ id: r.workspace_id, slug: r.workspaces.slug })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
9215
8987
|
}
|
|
9216
|
-
function
|
|
9217
|
-
const
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
);
|
|
8988
|
+
async function listDeviceWorkspaces(device) {
|
|
8989
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("workspace_id").eq("device_id", device.id);
|
|
8990
|
+
if (error2) fail("listDeviceWorkspaces", error2);
|
|
8991
|
+
const bound = new Set((data ?? []).map((r) => r.workspace_id));
|
|
8992
|
+
return (await listAdminWorkspaces(device.userId)).filter((w) => bound.has(w.id));
|
|
9221
8993
|
}
|
|
9222
|
-
function
|
|
9223
|
-
|
|
8994
|
+
async function bindDeviceWorkspace(deviceId, workspaceId, boundBy) {
|
|
8995
|
+
const { error: error2 } = await getSupabaseClient().from("device_workspaces").upsert(
|
|
8996
|
+
{ device_id: deviceId, workspace_id: workspaceId, bound_by: boundBy },
|
|
8997
|
+
{ onConflict: "device_id,workspace_id", ignoreDuplicates: true }
|
|
8998
|
+
);
|
|
8999
|
+
if (error2) fail("bindDeviceWorkspace", error2);
|
|
9224
9000
|
}
|
|
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
|
-
};
|
|
9001
|
+
async function listWorkspaceDevices(workspaceId) {
|
|
9002
|
+
const { data, error: error2 } = await getSupabaseClient().from("device_workspaces").select("devices!inner(*)").eq("workspace_id", workspaceId).is("devices.revoked_at", null);
|
|
9003
|
+
if (error2) fail("listWorkspaceDevices", error2);
|
|
9004
|
+
return (data ?? []).map((r) => toDevice(r.devices)).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
9236
9005
|
}
|
|
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);
|
|
9006
|
+
var STALE_HOST_MS = 14 * 24 * 60 * 6e4;
|
|
9007
|
+
var isStale = (lastSeenAt) => !!lastSeenAt && Date.now() - Date.parse(lastSeenAt) > STALE_HOST_MS;
|
|
9008
|
+
async function listWorkspaceHosts(workspaceIds, exceptPublicKey) {
|
|
9009
|
+
const hosts2 = /* @__PURE__ */ new Map();
|
|
9010
|
+
if (!workspaceIds.length) return hosts2;
|
|
9011
|
+
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);
|
|
9012
|
+
if (error2) fail("listWorkspaceHosts", error2);
|
|
9013
|
+
for (const r of data ?? []) {
|
|
9014
|
+
if (r.devices.public_key !== exceptPublicKey && !isStale(r.devices.last_seen_at)) {
|
|
9015
|
+
hosts2.set(r.workspace_id, r.devices.name);
|
|
9016
|
+
}
|
|
9275
9017
|
}
|
|
9018
|
+
return hosts2;
|
|
9276
9019
|
}
|
|
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);
|
|
9020
|
+
async function releaseStaleHosts(workspaceIds, exceptPublicKey) {
|
|
9021
|
+
if (!workspaceIds.length) return;
|
|
9022
|
+
const db = getSupabaseClient();
|
|
9023
|
+
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);
|
|
9024
|
+
if (error2) fail("releaseStaleHosts", error2);
|
|
9025
|
+
for (const r of data ?? []) {
|
|
9026
|
+
if (r.devices.public_key === exceptPublicKey || !isStale(r.devices.last_seen_at)) continue;
|
|
9027
|
+
const { error: delError } = await db.from("device_workspaces").delete().eq("device_id", r.device_id).eq("workspace_id", r.workspace_id);
|
|
9028
|
+
if (delError) fail("releaseStaleHosts", delError);
|
|
9029
|
+
}
|
|
9289
9030
|
}
|
|
9290
|
-
function
|
|
9291
|
-
|
|
9031
|
+
async function userEmail(userId) {
|
|
9032
|
+
const { data, error: error2 } = await getSupabaseClient().auth.admin.getUserById(userId);
|
|
9033
|
+
if (error2) return null;
|
|
9034
|
+
return data.user?.email ?? null;
|
|
9292
9035
|
}
|
|
9293
9036
|
|
|
9294
|
-
// ../api/src/dashboard/
|
|
9295
|
-
|
|
9296
|
-
|
|
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
|
-
}
|
|
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";
|
|
9307
9040
|
|
|
9308
|
-
// ../api/src/dashboard/
|
|
9309
|
-
import {
|
|
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"));
|
|
9055
|
+
}
|
|
9310
9056
|
|
|
9311
|
-
// ../
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
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);
|
|
9327
9074
|
}
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
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}`);
|
|
9075
|
+
function parseWireMessage(raw) {
|
|
9076
|
+
try {
|
|
9077
|
+
return JSON.parse(String(raw));
|
|
9078
|
+
} catch {
|
|
9079
|
+
return null;
|
|
9080
|
+
}
|
|
9341
9081
|
}
|
|
9342
9082
|
|
|
9343
|
-
// ../api/src/dashboard/
|
|
9344
|
-
var
|
|
9345
|
-
var
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
var MAX_MESSAGE_CHARS = 12e3;
|
|
9350
|
-
var MAX_OUTPUT_TOKENS2 = 32768;
|
|
9351
|
-
var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
|
|
9352
|
-
function isTranslatableLanguage2(lang) {
|
|
9353
|
-
return BY_CODE2.has(lang);
|
|
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);
|
|
9354
9089
|
}
|
|
9355
|
-
function
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9090
|
+
function removeHost(ws) {
|
|
9091
|
+
if (hosts.delete(ws)) notifyHostStatus();
|
|
9092
|
+
}
|
|
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");
|
|
9099
|
+
}
|
|
9100
|
+
}
|
|
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;
|
|
9359
9105
|
}
|
|
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
9106
|
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
- "
|
|
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
|
+
}
|
|
9366
9115
|
|
|
9367
|
-
|
|
9368
|
-
|
|
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.
|
|
9369
9131
|
|
|
9370
|
-
|
|
9371
|
-
-
|
|
9372
|
-
-
|
|
9373
|
-
-
|
|
9374
|
-
-
|
|
9375
|
-
-
|
|
9376
|
-
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
return
|
|
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, ""));
|
|
9385
9147
|
}
|
|
9386
|
-
function
|
|
9387
|
-
const
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
size = 0;
|
|
9396
|
-
}
|
|
9397
|
-
current.push(m);
|
|
9398
|
-
size += len;
|
|
9399
|
-
}
|
|
9400
|
-
if (current.length > 0) batches.push(current);
|
|
9401
|
-
return batches;
|
|
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");
|
|
9402
9157
|
}
|
|
9403
|
-
async function
|
|
9404
|
-
const
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
9412
|
-
|
|
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
|
-
});
|
|
9454
|
-
}
|
|
9455
|
-
var inFlight4 = /* @__PURE__ */ new Map();
|
|
9456
|
-
async function translateThread(threadId, lang, messages) {
|
|
9457
|
-
if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
|
|
9458
|
-
if (messages.length === 0) return [];
|
|
9459
|
-
const shape = createHash7("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
|
|
9460
|
-
const key = `${threadId}:${lang}:${shape}`;
|
|
9461
|
-
const running = inFlight4.get(key);
|
|
9462
|
-
if (running) return running;
|
|
9463
|
-
const job = (async () => {
|
|
9464
|
-
const hashes = messages.map((m) => ({
|
|
9465
|
-
replyId: m.id,
|
|
9466
|
-
bodyHash: translationHash2(m.author, m.body)
|
|
9467
|
-
}));
|
|
9468
|
-
const cached = await getThreadTranslations(lang, hashes);
|
|
9469
|
-
const missing = messages.filter((m) => !cached.has(m.id));
|
|
9470
|
-
const fresh = /* @__PURE__ */ new Map();
|
|
9471
|
-
if (missing.length > 0) {
|
|
9472
|
-
const index = new Map(messages.map((m, i) => [m.id, i]));
|
|
9473
|
-
const batches = batchMessages(missing);
|
|
9474
|
-
const results = await Promise.all(
|
|
9475
|
-
batches.map((batch) => {
|
|
9476
|
-
const first = index.get(batch[0].id) ?? 0;
|
|
9477
|
-
const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
|
|
9478
|
-
return translateBatch(batch, context, lang);
|
|
9479
|
-
})
|
|
9480
|
-
);
|
|
9481
|
-
for (const row of results.flat()) fresh.set(row.id, row);
|
|
9482
|
-
const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
|
|
9483
|
-
await putThreadTranslations(
|
|
9484
|
-
lang,
|
|
9485
|
-
[...fresh.values()].map((r) => ({
|
|
9486
|
-
replyId: r.id,
|
|
9487
|
-
bodyHash: hashById.get(r.id),
|
|
9488
|
-
body: r.body,
|
|
9489
|
-
passthrough: r.passthrough
|
|
9490
|
-
}))
|
|
9491
|
-
);
|
|
9492
|
-
}
|
|
9493
|
-
return messages.map((m) => {
|
|
9494
|
-
const hit = cached.get(m.id);
|
|
9495
|
-
if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
|
|
9496
|
-
return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
|
|
9497
|
-
});
|
|
9498
|
-
})().finally(() => inFlight4.delete(key));
|
|
9499
|
-
inFlight4.set(key, job);
|
|
9500
|
-
return job;
|
|
9199
|
+
return `${text ?? fallbackGreeting(input2)}
|
|
9200
|
+
|
|
9201
|
+
[BUTTONS: ${greetingButtons(input2).join(" | ")}]`;
|
|
9501
9202
|
}
|
|
9502
9203
|
|
|
9503
|
-
// ../api/src/dashboard/
|
|
9504
|
-
|
|
9505
|
-
|
|
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;
|
|
9506
9209
|
}
|
|
9507
|
-
function
|
|
9508
|
-
if (
|
|
9509
|
-
|
|
9510
|
-
return
|
|
9210
|
+
async function isHostOwner(ctx) {
|
|
9211
|
+
if (ctx.role !== "admin") return false;
|
|
9212
|
+
if (ctx.isSupport) return true;
|
|
9213
|
+
return (await listWorkspaceDevices(ctx.workspaceId)).some((d) => d.userId === ctx.userId);
|
|
9511
9214
|
}
|
|
9512
|
-
|
|
9513
|
-
|
|
9514
|
-
return sanitizeModel(data?.model);
|
|
9215
|
+
function hostUnavailable(c, agent, method) {
|
|
9216
|
+
return c.json({ error: "host offline", reason: agent.getHostCallFailure?.(method) ?? "offline" }, 503);
|
|
9515
9217
|
}
|
|
9516
|
-
function
|
|
9517
|
-
|
|
9218
|
+
function createOnboardingRouter(agent) {
|
|
9219
|
+
const app = new Hono7();
|
|
9220
|
+
app.get("/scan", async (c) => {
|
|
9221
|
+
const ctx = await resolveAuthContext(c);
|
|
9222
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9223
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9224
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9225
|
+
return c.json(scan);
|
|
9226
|
+
});
|
|
9227
|
+
app.post("/channels", async (c) => {
|
|
9228
|
+
const ctx = await resolveAuthContext(c);
|
|
9229
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9230
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9231
|
+
const runner = body["agent"];
|
|
9232
|
+
const folders = Array.isArray(body["folders"]) ? body["folders"].filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
9233
|
+
if (folders.length > MAX_FOLDERS) return c.json({ error: `at most ${MAX_FOLDERS} folders` }, 400);
|
|
9234
|
+
const scan = await agent.scanOnboarding?.();
|
|
9235
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9236
|
+
const offered = new Map(scan.projects.map((p) => [p.path, p]));
|
|
9237
|
+
const unknown = folders.find((f) => !offered.has(f));
|
|
9238
|
+
if (unknown) return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9239
|
+
const taken = new Set(getProfileChannelRegistry().map((ch) => ch.name));
|
|
9240
|
+
taken.add("general");
|
|
9241
|
+
const items = [{ name: "general" }];
|
|
9242
|
+
for (const path of folders) items.push({ name: uniqueChannelName(offered.get(path).name, taken), path });
|
|
9243
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), items);
|
|
9244
|
+
if (!linked) return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9245
|
+
if (runner === "claude" || runner === "codex") {
|
|
9246
|
+
await agent.saveLocalDashboardSettings?.({ providers: [{ runner }] });
|
|
9247
|
+
}
|
|
9248
|
+
const created = [];
|
|
9249
|
+
for (const l of linked) {
|
|
9250
|
+
created.push(
|
|
9251
|
+
l.name === "general" ? await ensureGeneralChannel() : await createChannel({ name: l.name, cwd: l.cwd })
|
|
9252
|
+
);
|
|
9253
|
+
}
|
|
9254
|
+
await markWorkspaceProjectsSetup(ctx.workspaceId);
|
|
9255
|
+
return c.json({ channels: created.map((ch) => ({ id: ch.id, name: ch.name })) }, 201);
|
|
9256
|
+
});
|
|
9257
|
+
app.post("/link-folder", async (c) => {
|
|
9258
|
+
const ctx = await resolveAuthContext(c);
|
|
9259
|
+
if (!isWorkspaceOwner(ctx)) return c.json({ error: "workspace owner access required" }, 403);
|
|
9260
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9261
|
+
const path = typeof body["path"] === "string" ? body["path"].trim() : "";
|
|
9262
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9263
|
+
if (!path || !CHANNEL_NAME_RE.test(name)) return c.json({ error: "path and a valid name are required" }, 400);
|
|
9264
|
+
if (getProfileChannelRegistry().some((ch) => ch.name === name)) {
|
|
9265
|
+
return c.json({ error: `#${name} already exists` }, 409);
|
|
9266
|
+
}
|
|
9267
|
+
if (body["manual"] === true) {
|
|
9268
|
+
const devices = await listWorkspaceDevices(ctx.workspaceId);
|
|
9269
|
+
if (!devices.some((d) => d.userId === ctx.userId)) {
|
|
9270
|
+
return c.json({ error: "a path can only be typed on your own machine" }, 403);
|
|
9271
|
+
}
|
|
9272
|
+
} else {
|
|
9273
|
+
const scan = await agent.scanOnboarding?.(getProfile());
|
|
9274
|
+
if (!scan) return hostUnavailable(c, agent, "scanOnboarding");
|
|
9275
|
+
if (!scan.projects.some((p) => p.path === path)) {
|
|
9276
|
+
return c.json({ error: "folder was not offered by the scan" }, 400);
|
|
9277
|
+
}
|
|
9278
|
+
}
|
|
9279
|
+
const linked = await agent.linkProjectFolders?.(getProfile(), [{ name, path }]);
|
|
9280
|
+
if (!linked) {
|
|
9281
|
+
const hostError = getHostCallError("linkProjectFolders");
|
|
9282
|
+
if (hostError) return c.json({ error: hostError }, 409);
|
|
9283
|
+
return hostUnavailable(c, agent, "linkProjectFolders");
|
|
9284
|
+
}
|
|
9285
|
+
return c.json({ cwd: linked[0].cwd });
|
|
9286
|
+
});
|
|
9287
|
+
app.post("/reset", async (c) => {
|
|
9288
|
+
const ctx = await resolveAuthContext(c);
|
|
9289
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9290
|
+
const { error: error2 } = await getSupabaseClient().from("threads").delete().eq("client_key", ctx.workspaceId);
|
|
9291
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
9292
|
+
await resetWorkspaceOnboarding(ctx.workspaceId);
|
|
9293
|
+
return c.json({ ok: true });
|
|
9294
|
+
});
|
|
9295
|
+
app.get("/greeting", async (c) => {
|
|
9296
|
+
const ctx = await resolveAuthContext(c);
|
|
9297
|
+
if (ctx?.role !== "admin") return c.json({ pending: false });
|
|
9298
|
+
const greetedAt = await getWorkspaceGreetedAt(ctx.workspaceId);
|
|
9299
|
+
const pending2 = !!ctx.projectsSetupAt && !greetedAt;
|
|
9300
|
+
if (!pending2) return c.json({ pending: pending2, channelId: null, threadId: null });
|
|
9301
|
+
const general = await ensureGeneralChannel();
|
|
9302
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ctx.name?.trim() || ctx.handle);
|
|
9303
|
+
return c.json({ pending: pending2, channelId: general.id, threadId });
|
|
9304
|
+
});
|
|
9305
|
+
app.post("/greeting", async (c) => {
|
|
9306
|
+
const ctx = await resolveAuthContext(c);
|
|
9307
|
+
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9308
|
+
const supabase = getSupabaseClient();
|
|
9309
|
+
const general = await ensureGeneralChannel();
|
|
9310
|
+
const ownerName = ctx.name?.trim() || ctx.handle;
|
|
9311
|
+
const threadId = await ensureGreetingThread(ctx.workspaceId, general.id, ownerName);
|
|
9312
|
+
if (!threadId) return c.json({ error: "could not create the welcome thread" }, 500);
|
|
9313
|
+
const done = { channelId: general.id, threadId };
|
|
9314
|
+
const replied = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9315
|
+
if (replied.data) return c.json(done);
|
|
9316
|
+
if (await getWorkspaceGreetedAt(ctx.workspaceId))
|
|
9317
|
+
return c.json({ error: "already greeted" }, 409);
|
|
9318
|
+
const [branding, billing, members] = await Promise.all([
|
|
9319
|
+
getWorkspaceBranding(getProfile()).catch(() => null),
|
|
9320
|
+
getWorkspaceBilling(ctx.workspaceId).catch(() => null),
|
|
9321
|
+
listMemberships(ctx.workspaceId).catch(() => [])
|
|
9322
|
+
]);
|
|
9323
|
+
const body = await buildGreeting({
|
|
9324
|
+
ownerName,
|
|
9325
|
+
workspaceName: branding?.displayName || getProfile(),
|
|
9326
|
+
trialing: billing?.planStatus === "trialing",
|
|
9327
|
+
hostConnected: !!ctx.agentConnectedAt,
|
|
9328
|
+
channels: getProfileChannelRegistry().filter((ch) => !ch.isDefault && !ch.dmKey && !ch.agentName && !ch.archivedAt).map((ch) => ch.name),
|
|
9329
|
+
memberCount: members.filter((m) => !m.isSupport).length
|
|
9330
|
+
});
|
|
9331
|
+
const again = await supabase.from("thread_replies").select("id").eq("thread_id", threadId).limit(1).maybeSingle();
|
|
9332
|
+
if (again.data) return c.json(done);
|
|
9333
|
+
const reply = await supabase.from("thread_replies").insert({ thread_id: threadId, author: AGENT_AUTHOR, body });
|
|
9334
|
+
if (reply.error) return c.json({ error: reply.error.message }, 500);
|
|
9335
|
+
await markWorkspaceGreeted(ctx.workspaceId);
|
|
9336
|
+
return c.json(done, 201);
|
|
9337
|
+
});
|
|
9338
|
+
return app;
|
|
9518
9339
|
}
|
|
9519
|
-
function
|
|
9520
|
-
|
|
9521
|
-
const
|
|
9522
|
-
|
|
9340
|
+
async function ensureGreetingThread(workspaceId, channelId, ownerName) {
|
|
9341
|
+
const supabase = getSupabaseClient();
|
|
9342
|
+
const existing = () => supabase.from("threads").select("id").eq("client_key", workspaceId).maybeSingle();
|
|
9343
|
+
const found = await existing();
|
|
9344
|
+
if (found.data) return found.data.id;
|
|
9345
|
+
const { data, error: error2 } = await supabase.from("threads").insert({
|
|
9346
|
+
channel_id: channelId,
|
|
9347
|
+
title: `Welcome to your Flyspace, ${ownerName}!`,
|
|
9348
|
+
author: AGENT_AUTHOR,
|
|
9349
|
+
client_key: workspaceId
|
|
9350
|
+
}).select("id").single();
|
|
9351
|
+
if (data) return data.id;
|
|
9352
|
+
return error2?.code === "23505" ? (await existing()).data?.id ?? null : null;
|
|
9523
9353
|
}
|
|
9524
|
-
|
|
9525
|
-
|
|
9354
|
+
|
|
9355
|
+
// ../api/src/dashboard/auth-routes.ts
|
|
9356
|
+
function createAuthRouter() {
|
|
9357
|
+
const auth = new Hono8();
|
|
9358
|
+
const emailedLink = (deliver) => async (c) => {
|
|
9359
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9360
|
+
const email = body.email?.trim().toLowerCase();
|
|
9361
|
+
if (!email) return c.json({ error: "email is required" }, 400);
|
|
9362
|
+
if (!isDevHost(c) && !await verifyTurnstileToken(body.captchaToken)) {
|
|
9363
|
+
return c.json({ error: "captcha check failed \u2014 please retry" }, 400);
|
|
9364
|
+
}
|
|
9365
|
+
const workspace = await getWorkspaceBySlug(getProfile());
|
|
9366
|
+
if (!workspace) return c.json({ error: "unknown workspace" }, 404);
|
|
9367
|
+
const membership = await getMembershipByEmail(workspace.id, email);
|
|
9368
|
+
if (!membership) {
|
|
9369
|
+
return c.json({ error: "no access \u2014 ask an admin to invite you" }, 403);
|
|
9370
|
+
}
|
|
9371
|
+
try {
|
|
9372
|
+
await deliver(email, callbackUrl(c));
|
|
9373
|
+
} catch (err) {
|
|
9374
|
+
return c.json({ error: err instanceof Error ? err.message : "could not send" }, 500);
|
|
9375
|
+
}
|
|
9376
|
+
return c.json({ sent: true });
|
|
9377
|
+
};
|
|
9378
|
+
auth.post("/login", emailedLink(deliverSignInLink));
|
|
9379
|
+
auth.post("/forgot-password", emailedLink(deliverPasswordResetLink));
|
|
9380
|
+
auth.get("/session", async (c) => {
|
|
9381
|
+
const ctx = await resolveAuthContext(c, { fresh: c.req.query("fresh") === "1" });
|
|
9382
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9383
|
+
return c.json({
|
|
9384
|
+
email: ctx.email,
|
|
9385
|
+
handle: ctx.handle,
|
|
9386
|
+
role: ctx.role,
|
|
9387
|
+
name: ctx.name,
|
|
9388
|
+
avatarUrl: ctx.isSupport ? SUPPORT_AVATAR_URL : ctx.avatarKey ? await signAvatarUrl(ctx.avatarKey) : null,
|
|
9389
|
+
onboarded: ctx.onboarded,
|
|
9390
|
+
passwordSet: ctx.passwordSet,
|
|
9391
|
+
agentConnectedAt: ctx.agentConnectedAt,
|
|
9392
|
+
plan: ctx.plan ?? null,
|
|
9393
|
+
onboarding: { pending: onboardingPending(ctx) },
|
|
9394
|
+
owner: isWorkspaceOwner(ctx),
|
|
9395
|
+
// Only the Host's owner can run `flyspace upgrade` on it, so other
|
|
9396
|
+
// admins never see the upgrade banner. Support staff can tell them to.
|
|
9397
|
+
hostOwner: await isHostOwner(ctx)
|
|
9398
|
+
});
|
|
9399
|
+
});
|
|
9400
|
+
auth.patch("/handle", async (c) => {
|
|
9401
|
+
const ctx = await resolveAuthContext(c);
|
|
9402
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9403
|
+
if (ctx.onboarded) return c.json({ error: "handle can no longer be changed" }, 403);
|
|
9404
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9405
|
+
const handle = body.handle?.trim().replace(/^@/, "").toLowerCase() ?? "";
|
|
9406
|
+
if (!HANDLE_PATTERN.test(handle)) {
|
|
9407
|
+
return c.json(
|
|
9408
|
+
{ error: "handle must be 1-32 characters: a-z, 0-9, _ or -, starting with a letter or digit" },
|
|
9409
|
+
400
|
|
9410
|
+
);
|
|
9411
|
+
}
|
|
9412
|
+
try {
|
|
9413
|
+
await setMembershipHandle(ctx.workspaceId, ctx.userId, handle);
|
|
9414
|
+
} catch (err) {
|
|
9415
|
+
if (err instanceof HandleTakenError) return c.json({ error: err.message }, 409);
|
|
9416
|
+
throw err;
|
|
9417
|
+
}
|
|
9418
|
+
return c.json({ handle });
|
|
9419
|
+
});
|
|
9420
|
+
auth.post("/logout", async (c) => {
|
|
9421
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9422
|
+
await supabase.auth.signOut();
|
|
9423
|
+
return c.json({ ok: true });
|
|
9424
|
+
});
|
|
9425
|
+
return auth;
|
|
9526
9426
|
}
|
|
9527
|
-
|
|
9528
|
-
|
|
9427
|
+
|
|
9428
|
+
// ../api/src/dashboard/auth-middleware.ts
|
|
9429
|
+
var requireWorkspaceMembership = async (c, next) => {
|
|
9430
|
+
const ctx = await resolveAuthContext(c);
|
|
9431
|
+
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
9432
|
+
c.set("auth", ctx);
|
|
9433
|
+
return next();
|
|
9434
|
+
};
|
|
9435
|
+
|
|
9436
|
+
// ../api/src/dashboard/billing-routes.ts
|
|
9437
|
+
import { Hono as Hono9 } from "hono";
|
|
9438
|
+
function isPlan(value) {
|
|
9439
|
+
return value === "local" || value === "cloud";
|
|
9529
9440
|
}
|
|
9530
|
-
var
|
|
9531
|
-
|
|
9532
|
-
"
|
|
9533
|
-
|
|
9534
|
-
"
|
|
9535
|
-
|
|
9536
|
-
|
|
9537
|
-
|
|
9538
|
-
var NO_STATUS_COLUMN = "none";
|
|
9539
|
-
function boardColumnParam(status) {
|
|
9540
|
-
if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
|
|
9541
|
-
return BOARD_STATUSES.includes(status) ? status : null;
|
|
9542
|
-
}
|
|
9543
|
-
var THREAD_CHUNK_SIZE = 25;
|
|
9544
|
-
var THREADS_PAGE_SIZE = 30;
|
|
9545
|
-
var THREADS_PAGE_SIZE_MAX = 100;
|
|
9546
|
-
var REPLIES_PAGE_SIZE = 50;
|
|
9547
|
-
var REPLIES_PAGE_SIZE_MAX = 200;
|
|
9548
|
-
var TRANSLATABLE_REPLIES_MAX = 400;
|
|
9549
|
-
var BOARD_PAGE_SIZE = 50;
|
|
9550
|
-
var BOARD_PAGE_SIZE_MAX = 100;
|
|
9551
|
-
function ownChannelIds(c) {
|
|
9552
|
-
const viewer = c.get("auth");
|
|
9553
|
-
return new Set(
|
|
9554
|
-
getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
|
|
9555
|
-
);
|
|
9556
|
-
}
|
|
9557
|
-
async function globalChannelMeta(c) {
|
|
9558
|
-
const viewer = c.get("auth");
|
|
9559
|
-
const map = /* @__PURE__ */ new Map();
|
|
9560
|
-
const channels = getChannelRegistry();
|
|
9561
|
-
let workspaceIdBySlug = /* @__PURE__ */ new Map();
|
|
9562
|
-
let roleByWorkspaceId = /* @__PURE__ */ new Map();
|
|
9563
|
-
if (viewer?.email && channels.length > 0) {
|
|
9564
|
-
const [workspaces, memberships] = await Promise.all([
|
|
9565
|
-
listWorkspaces(),
|
|
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);
|
|
9441
|
+
var LOCK_EXEMPT = [/\/billing(\/|$)/, /\/profiles?$/, /\/push\//];
|
|
9442
|
+
async function requireActivePlan(c, next) {
|
|
9443
|
+
if (c.req.method === "OPTIONS") return next();
|
|
9444
|
+
if (LOCK_EXEMPT.some((re) => re.test(c.req.path))) return next();
|
|
9445
|
+
const auth = c.get("auth");
|
|
9446
|
+
if (!auth) return next();
|
|
9447
|
+
if (isLockedPlan(await getWorkspacePlanStatus(auth.workspaceId))) {
|
|
9448
|
+
return c.json({ error: "trial ended: add a card to reactivate this workspace", code: "plan_locked" }, 402);
|
|
9578
9449
|
}
|
|
9579
|
-
return
|
|
9580
|
-
}
|
|
9581
|
-
async function threadIsKnown(threadId, c) {
|
|
9582
|
-
const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
|
|
9583
|
-
const channelId = data?.channel_id;
|
|
9584
|
-
return !!channelId && (await globalChannelMeta(c)).has(channelId);
|
|
9585
|
-
}
|
|
9586
|
-
function chunk(items, size) {
|
|
9587
|
-
const out = [];
|
|
9588
|
-
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
9589
|
-
return out;
|
|
9450
|
+
return next();
|
|
9590
9451
|
}
|
|
9591
|
-
|
|
9592
|
-
const
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
)
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
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()
|
|
9615
|
-
});
|
|
9452
|
+
function createBillingRouter() {
|
|
9453
|
+
const billing = new Hono9();
|
|
9454
|
+
billing.get("/", async (c) => {
|
|
9455
|
+
const auth = c.get("auth");
|
|
9456
|
+
const { planStatus, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9457
|
+
let trialEnd = null;
|
|
9458
|
+
let plan = null;
|
|
9459
|
+
if (stripeSubscriptionId) {
|
|
9460
|
+
try {
|
|
9461
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9462
|
+
if (planStatus === "trialing" && sub.trial_end) {
|
|
9463
|
+
trialEnd = new Date(sub.trial_end * 1e3).toISOString();
|
|
9464
|
+
}
|
|
9465
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9466
|
+
} catch (err) {
|
|
9467
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9468
|
+
}
|
|
9616
9469
|
}
|
|
9617
|
-
|
|
9618
|
-
return threads.map((t) => {
|
|
9619
|
-
const entry = previewByThread.get(t.id);
|
|
9620
|
-
return {
|
|
9621
|
-
...t,
|
|
9622
|
-
reply_count: Math.max(0, (entry?.count ?? 0) - 1),
|
|
9623
|
-
preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
|
|
9624
|
-
// Who spoke last, counting only replies AFTER the opening message —
|
|
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
|
-
};
|
|
9470
|
+
return c.json({ planStatus, trialEnd, plan, locked: isLockedPlan(planStatus) });
|
|
9631
9471
|
});
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9472
|
+
billing.post("/checkout", async (c) => {
|
|
9473
|
+
const auth = c.get("auth");
|
|
9474
|
+
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
9475
|
+
const { stripeCustomerId, stripeSubscriptionId } = await getWorkspaceBilling(auth.workspaceId);
|
|
9476
|
+
if (!stripeCustomerId) return c.json({ error: "no subscription for this workspace" }, 409);
|
|
9477
|
+
let plan = null;
|
|
9478
|
+
if (stripeSubscriptionId) {
|
|
9479
|
+
try {
|
|
9480
|
+
const sub = await getStripeClient().subscriptions.retrieve(stripeSubscriptionId);
|
|
9481
|
+
if (isPlan(sub.metadata["plan"])) plan = PLAN_INFO[sub.metadata["plan"]];
|
|
9482
|
+
} catch (err) {
|
|
9483
|
+
console.warn("[billing] subscription lookup failed:", err);
|
|
9484
|
+
}
|
|
9485
|
+
}
|
|
9486
|
+
const origin = c.req.header("origin") ?? `https://${trustedHost(c)}`;
|
|
9487
|
+
const session = await getStripeClient().checkout.sessions.create({
|
|
9488
|
+
mode: "setup",
|
|
9489
|
+
currency: "usd",
|
|
9490
|
+
customer: stripeCustomerId,
|
|
9491
|
+
success_url: `${origin}/?billing=done`,
|
|
9492
|
+
cancel_url: `${origin}/`,
|
|
9493
|
+
metadata: { workspace_id: auth.workspaceId },
|
|
9494
|
+
...plan && {
|
|
9495
|
+
custom_text: {
|
|
9496
|
+
submit: {
|
|
9497
|
+
message: `${plan.name} plan, US$${plan.priceUsd}/month. The first month is charged as soon as this card is saved, then monthly until cancelled.`
|
|
9498
|
+
}
|
|
9499
|
+
}
|
|
9500
|
+
}
|
|
9501
|
+
});
|
|
9502
|
+
if (!session.url) return c.json({ error: "Stripe returned no checkout URL" }, 502);
|
|
9503
|
+
return c.json({ url: session.url });
|
|
9654
9504
|
});
|
|
9505
|
+
return billing;
|
|
9655
9506
|
}
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9507
|
+
|
|
9508
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9509
|
+
import { Hono as Hono10 } from "hono";
|
|
9510
|
+
|
|
9511
|
+
// ../api/src/dashboard/admin-middleware.ts
|
|
9512
|
+
function platformAdminEmails() {
|
|
9513
|
+
const raw = process.env["PLATFORM_ADMIN_EMAILS"] ?? "";
|
|
9514
|
+
return new Set(
|
|
9515
|
+
raw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean)
|
|
9516
|
+
);
|
|
9662
9517
|
}
|
|
9663
|
-
|
|
9664
|
-
const
|
|
9518
|
+
var requirePlatformOperator = async (c, next) => {
|
|
9519
|
+
const supabase = createRequestSupabaseClient(c);
|
|
9665
9520
|
const {
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
}
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
);
|
|
9681
|
-
|
|
9682
|
-
|
|
9683
|
-
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
|
|
9698
|
-
|
|
9699
|
-
|
|
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
|
-
}
|
|
9715
|
-
}
|
|
9716
|
-
const { data: threads, error: error2 } = await query;
|
|
9717
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9718
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9719
|
-
const withPreview = await withPreviews(supabase, threads);
|
|
9720
|
-
const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
|
|
9721
|
-
return c.json({ threads: withPreview, nextCursor });
|
|
9722
|
-
});
|
|
9723
|
-
app.get("/channels/:channelId/board/:status", async (c) => {
|
|
9724
|
-
const channelId = c.req.param("channelId");
|
|
9725
|
-
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
9726
|
-
const column = boardColumnParam(c.req.param("status"));
|
|
9727
|
-
if (!column) {
|
|
9728
|
-
return c.json(
|
|
9729
|
-
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
9730
|
-
400
|
|
9731
|
-
);
|
|
9732
|
-
}
|
|
9733
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
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 });
|
|
9747
|
-
});
|
|
9748
|
-
app.get("/board/:status", async (c) => {
|
|
9749
|
-
const column = boardColumnParam(c.req.param("status"));
|
|
9750
|
-
if (!column) {
|
|
9751
|
-
return c.json(
|
|
9752
|
-
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
9753
|
-
400
|
|
9754
|
-
);
|
|
9755
|
-
}
|
|
9756
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
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 });
|
|
9780
|
-
});
|
|
9781
|
-
app.get("/threads", async (c) => {
|
|
9782
|
-
const requestedLimit = Number(c.req.query("limit"));
|
|
9783
|
-
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
9784
|
-
const before = c.req.query("before");
|
|
9785
|
-
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
9786
|
-
const relevantTo = c.req.query("relevantTo");
|
|
9787
|
-
const supabase = getSupabaseClient();
|
|
9788
|
-
const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
|
|
9789
|
-
let threads;
|
|
9790
|
-
if (isNonEmptyString(relevantTo)) {
|
|
9791
|
-
try {
|
|
9792
|
-
threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
|
|
9793
|
-
} catch (err) {
|
|
9794
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
9795
|
-
}
|
|
9796
|
-
} else {
|
|
9797
|
-
let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
9798
|
-
if (before) query = query.lt("updated_at", before);
|
|
9799
|
-
const { data, error: error2 } = await query;
|
|
9800
|
-
if (error2) return c.json({ error: error2.message }, 500);
|
|
9801
|
-
threads = data;
|
|
9802
|
-
}
|
|
9803
|
-
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
9804
|
-
const threadIds = threads.map((t) => t.id);
|
|
9805
|
-
const chunkResults = await Promise.all(
|
|
9806
|
-
chunk(threadIds, THREAD_CHUNK_SIZE).map(
|
|
9807
|
-
(ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
9808
|
-
)
|
|
9521
|
+
data: { user }
|
|
9522
|
+
} = await supabase.auth.getUser();
|
|
9523
|
+
if (!user?.email) return c.json({ error: "not signed in" }, 401);
|
|
9524
|
+
const email = user.email.trim().toLowerCase();
|
|
9525
|
+
if (!platformAdminEmails().has(email)) {
|
|
9526
|
+
return c.json({ error: "platform operator access required" }, 403);
|
|
9527
|
+
}
|
|
9528
|
+
c.set("platformOperator", { userId: user.id, email });
|
|
9529
|
+
return next();
|
|
9530
|
+
};
|
|
9531
|
+
|
|
9532
|
+
// ../api/src/dashboard/admin-routes.ts
|
|
9533
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9534
|
+
function createAdminRouter() {
|
|
9535
|
+
const admin = new Hono10();
|
|
9536
|
+
admin.use("*", requirePlatformOperator);
|
|
9537
|
+
admin.get("/workspaces", async (c) => {
|
|
9538
|
+
const summaries = await listWorkspaceSummaries();
|
|
9539
|
+
const workspaces = await Promise.all(
|
|
9540
|
+
summaries.map(async (w) => {
|
|
9541
|
+
const agentModel = cachedProfileConfig(w.slug)?.agentModel;
|
|
9542
|
+
const r2Bytes = await Promise.all(
|
|
9543
|
+
["chat", SCRATCH_PREFIX].map((prefix) => sumR2PrefixBytes(`${prefix}/${w.slug}/`))
|
|
9544
|
+
).then(([a, b]) => a + b).catch((err) => {
|
|
9545
|
+
console.error(`[admin] R2 usage for ${w.slug}:`, err.message);
|
|
9546
|
+
return null;
|
|
9547
|
+
});
|
|
9548
|
+
return {
|
|
9549
|
+
...w,
|
|
9550
|
+
agent: agentModel ? { runner: runnerForModel(agentModel), model: agentModel } : null,
|
|
9551
|
+
r2Bytes,
|
|
9552
|
+
flyVolumeBytes: null
|
|
9553
|
+
};
|
|
9554
|
+
})
|
|
9809
9555
|
);
|
|
9810
|
-
|
|
9811
|
-
for (const { data } of chunkResults) {
|
|
9812
|
-
for (const r of data ?? []) {
|
|
9813
|
-
const arr = repliesByThread.get(r.thread_id);
|
|
9814
|
-
if (arr) arr.push(r);
|
|
9815
|
-
else repliesByThread.set(r.thread_id, [r]);
|
|
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 });
|
|
9556
|
+
return c.json({ workspaces });
|
|
9835
9557
|
});
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
}
|
|
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 });
|
|
9558
|
+
admin.get("/invites", async (c) => {
|
|
9559
|
+
const invites = await listSetupInvites();
|
|
9560
|
+
return c.json({ invites });
|
|
9852
9561
|
});
|
|
9853
|
-
|
|
9854
|
-
const
|
|
9855
|
-
const
|
|
9856
|
-
|
|
9857
|
-
|
|
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);
|
|
9562
|
+
admin.post("/setup-invites", async (c) => {
|
|
9563
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9564
|
+
const ownerEmail = body.ownerEmail?.trim().toLowerCase();
|
|
9565
|
+
if (!ownerEmail || !EMAIL_RE.test(ownerEmail)) {
|
|
9566
|
+
return c.json({ error: "ownerEmail must be a valid email address" }, 400);
|
|
9878
9567
|
}
|
|
9879
|
-
const
|
|
9880
|
-
|
|
9881
|
-
const
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
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
|
|
9568
|
+
const plan = body.plan === "cloud" ? "cloud" : body.plan === "local" ? "local" : void 0;
|
|
9569
|
+
if (!plan) return c.json({ error: 'plan must be "local" or "cloud"' }, 400);
|
|
9570
|
+
const ownerName = body.ownerName?.trim().slice(0, 80) || null;
|
|
9571
|
+
const message = body.message?.trim() || null;
|
|
9572
|
+
if (message && message.length > INVITE_MESSAGE_MAX) {
|
|
9573
|
+
return c.json({ error: `message must be ${INVITE_MESSAGE_MAX} characters or fewer` }, 400);
|
|
9574
|
+
}
|
|
9575
|
+
const operator = c.get("platformOperator");
|
|
9576
|
+
const token = await issueSetupInvite({
|
|
9577
|
+
email: ownerEmail,
|
|
9578
|
+
ownerName,
|
|
9579
|
+
plan,
|
|
9580
|
+
message,
|
|
9581
|
+
invitedBy: operator.userId
|
|
9899
9582
|
});
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
|
|
9905
|
-
const [thread, replies] = await Promise.all([
|
|
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);
|
|
9583
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9584
|
+
inviteUrl.searchParams.set("token", token);
|
|
9585
|
+
if (body.sendEmail === false) {
|
|
9586
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9914
9587
|
}
|
|
9915
|
-
const messages = (replies.data ?? []).map(
|
|
9916
|
-
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
9917
|
-
);
|
|
9918
9588
|
try {
|
|
9919
|
-
|
|
9589
|
+
await deliverWorkspaceSetupInvite({
|
|
9590
|
+
email: ownerEmail,
|
|
9591
|
+
inviteLink: inviteUrl.toString(),
|
|
9592
|
+
message
|
|
9593
|
+
});
|
|
9920
9594
|
} catch (err) {
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
return c.json(
|
|
9595
|
+
await deleteSetupInvite(token).catch(() => {
|
|
9596
|
+
});
|
|
9597
|
+
return c.json(
|
|
9598
|
+
{
|
|
9599
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9600
|
+
},
|
|
9601
|
+
500
|
|
9602
|
+
);
|
|
9924
9603
|
}
|
|
9604
|
+
return c.json({ email: ownerEmail, inviteLink: inviteUrl.toString() }, 201);
|
|
9925
9605
|
});
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
|
|
9930
|
-
|
|
9606
|
+
admin.delete("/setup-invites/:id", async (c) => {
|
|
9607
|
+
await revokeSetupInvite(c.req.param("id"));
|
|
9608
|
+
return c.json({ revoked: true });
|
|
9609
|
+
});
|
|
9610
|
+
admin.post("/setup-invites/:id/resend", async (c) => {
|
|
9611
|
+
const operator = c.get("platformOperator");
|
|
9612
|
+
let token;
|
|
9613
|
+
let invite;
|
|
9614
|
+
try {
|
|
9615
|
+
({ token, invite } = await reissueSetupInvite(c.req.param("id")));
|
|
9616
|
+
} catch (err) {
|
|
9617
|
+
return c.json({ error: err instanceof Error ? err.message : "resend failed" }, 404);
|
|
9618
|
+
}
|
|
9619
|
+
const inviteUrl = new URL(`${workspaceOrigin(c, "app")}/auth/new-workspace`);
|
|
9620
|
+
inviteUrl.searchParams.set("token", token);
|
|
9621
|
+
if (c.req.query("email") === "false") {
|
|
9622
|
+
return c.json({ sent: false, inviteLink: inviteUrl.toString() });
|
|
9623
|
+
}
|
|
9624
|
+
try {
|
|
9625
|
+
await deliverWorkspaceSetupInvite({
|
|
9626
|
+
email: invite.email,
|
|
9627
|
+
inviteLink: inviteUrl.toString(),
|
|
9628
|
+
message: invite.message
|
|
9629
|
+
});
|
|
9630
|
+
} catch (err) {
|
|
9931
9631
|
return c.json(
|
|
9932
|
-
{
|
|
9933
|
-
|
|
9632
|
+
{
|
|
9633
|
+
error: `the invite email failed: ${err instanceof Error ? err.message : "unknown error"}`
|
|
9634
|
+
},
|
|
9635
|
+
500
|
|
9934
9636
|
);
|
|
9935
9637
|
}
|
|
9936
|
-
|
|
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);
|
|
9638
|
+
return c.json({ sent: true, inviteLink: inviteUrl.toString() });
|
|
9940
9639
|
});
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9640
|
+
return admin;
|
|
9641
|
+
}
|
|
9642
|
+
|
|
9643
|
+
// ../api/src/dashboard/agent-identities.ts
|
|
9644
|
+
var BOT_LABEL = "Workspace Agent";
|
|
9645
|
+
var BOT_DM_KEY = "claude";
|
|
9646
|
+
|
|
9647
|
+
// ../api/src/dashboard/data-api.ts
|
|
9648
|
+
import { existsSync as existsSync19 } from "fs";
|
|
9649
|
+
import sharp5 from "sharp";
|
|
9650
|
+
|
|
9651
|
+
// ../api/src/dashboard/automations-routes.ts
|
|
9652
|
+
import { Hono as Hono11 } from "hono";
|
|
9653
|
+
var TYPES2 = ["schedule", "trigger", "mention-trigger"];
|
|
9654
|
+
function isType(t) {
|
|
9655
|
+
return TYPES2.includes(t);
|
|
9656
|
+
}
|
|
9657
|
+
function sanitize(body) {
|
|
9658
|
+
const { created_by: _b, created_at: _a, ...rest } = body;
|
|
9659
|
+
void _b;
|
|
9660
|
+
void _a;
|
|
9661
|
+
return rest;
|
|
9662
|
+
}
|
|
9663
|
+
function statusFor(message) {
|
|
9664
|
+
if (/already exists/.test(message)) return 409;
|
|
9665
|
+
if (/not found/.test(message)) return 404;
|
|
9666
|
+
return 400;
|
|
9667
|
+
}
|
|
9668
|
+
function createAutomationWriteRouter() {
|
|
9669
|
+
const app = new Hono11();
|
|
9670
|
+
const manager = () => new AutomationManager();
|
|
9671
|
+
app.get("/:type/:name", (c) => {
|
|
9672
|
+
const item = manager().get(c.req.param("name"));
|
|
9673
|
+
if (!item) return c.json({ error: "not found" }, 404);
|
|
9674
|
+
return c.json(item);
|
|
9675
|
+
});
|
|
9676
|
+
app.post("/:type", async (c) => {
|
|
9677
|
+
const type = c.req.param("type");
|
|
9678
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9679
|
+
const body = sanitize(await c.req.json());
|
|
9680
|
+
const next = { ...body, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
9681
|
+
try {
|
|
9682
|
+
const m = manager();
|
|
9683
|
+
if (type === "schedule") await m.addSchedule(next);
|
|
9684
|
+
else if (type === "trigger") await m.addTrigger(next);
|
|
9685
|
+
else await m.addMentionTrigger(next);
|
|
9686
|
+
return c.json(next, 201);
|
|
9687
|
+
} catch (e) {
|
|
9688
|
+
const msg = e.message;
|
|
9689
|
+
const status = statusFor(msg);
|
|
9690
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9947
9691
|
}
|
|
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
9692
|
});
|
|
9954
|
-
app.
|
|
9955
|
-
const
|
|
9956
|
-
if (!
|
|
9957
|
-
const
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
9693
|
+
app.put("/:type/:name", async (c) => {
|
|
9694
|
+
const type = c.req.param("type");
|
|
9695
|
+
if (!isType(type)) return c.json({ error: "unknown type" }, 400);
|
|
9696
|
+
const name = c.req.param("name");
|
|
9697
|
+
const body = sanitize(await c.req.json());
|
|
9698
|
+
try {
|
|
9699
|
+
const m = manager();
|
|
9700
|
+
const existing = m.get(name);
|
|
9701
|
+
const next = {
|
|
9702
|
+
...body,
|
|
9703
|
+
created_at: existing?.created_at
|
|
9704
|
+
};
|
|
9705
|
+
await m.update(type, name, next);
|
|
9706
|
+
return c.json(next, 200);
|
|
9707
|
+
} catch (e) {
|
|
9708
|
+
const msg = e.message;
|
|
9709
|
+
const status = statusFor(msg);
|
|
9710
|
+
return c.json(status === 400 ? { errors: [msg] } : { error: msg }, status);
|
|
9711
|
+
}
|
|
9965
9712
|
});
|
|
9966
|
-
app.
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
9970
|
-
|
|
9971
|
-
|
|
9972
|
-
return c.json({ errors: ["author and body are required"] }, 400);
|
|
9713
|
+
app.delete("/:type/:name", async (c) => {
|
|
9714
|
+
try {
|
|
9715
|
+
await manager().remove(c.req.param("name"));
|
|
9716
|
+
return c.json({ ok: true }, 200);
|
|
9717
|
+
} catch (e) {
|
|
9718
|
+
return c.json({ error: e.message }, 404);
|
|
9973
9719
|
}
|
|
9720
|
+
});
|
|
9721
|
+
return app;
|
|
9722
|
+
}
|
|
9723
|
+
|
|
9724
|
+
// ../api/src/dashboard/categories-routes.ts
|
|
9725
|
+
import { Hono as Hono12 } from "hono";
|
|
9726
|
+
function createCategoryWriteRouter() {
|
|
9727
|
+
const app = new Hono12();
|
|
9728
|
+
app.get("/", async (c) => {
|
|
9729
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").select("*").eq("profile", getProfile()).order("position", { ascending: true });
|
|
9730
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
9731
|
+
return c.json({ categories: data ?? [] });
|
|
9732
|
+
});
|
|
9733
|
+
app.post("/", async (c) => {
|
|
9734
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9735
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9736
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9974
9737
|
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();
|
|
9738
|
+
const { data: last } = await supabase.from("categories").select("position").eq("profile", getProfile()).order("position", { ascending: false }).limit(1);
|
|
9739
|
+
const nextPosition = (last?.[0]?.position ?? -1) + 1;
|
|
9740
|
+
const { data, error: error2 } = await supabase.from("categories").insert({ name, position: nextPosition, profile: getProfile() }).select().single();
|
|
9982
9741
|
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);
|
|
9742
|
+
return c.json(data, 201);
|
|
9991
9743
|
});
|
|
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
|
-
}
|
|
9744
|
+
app.patch("/reorder", async (c) => {
|
|
9745
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9746
|
+
const ids = Array.isArray(body["ids"]) ? body["ids"].filter((x) => typeof x === "string") : [];
|
|
9747
|
+
if (ids.length === 0) return c.json({ errors: ["ids is required"] }, 400);
|
|
10000
9748
|
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 ?? [] });
|
|
9749
|
+
const results = await Promise.all(
|
|
9750
|
+
ids.map(
|
|
9751
|
+
(id, position) => supabase.from("categories").update({ position, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile())
|
|
9752
|
+
)
|
|
9753
|
+
);
|
|
9754
|
+
const failed2 = results.find((r) => r.error);
|
|
9755
|
+
if (failed2?.error) return c.json({ error: failed2.error.message }, 500);
|
|
9756
|
+
return c.json({ ok: true });
|
|
10016
9757
|
});
|
|
10017
|
-
app.
|
|
10018
|
-
if (c.get("auth")?.role !== "admin") {
|
|
10019
|
-
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10020
|
-
}
|
|
9758
|
+
app.put("/:id", async (c) => {
|
|
10021
9759
|
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);
|
|
9760
|
+
const body = await c.req.json().catch(() => ({}));
|
|
9761
|
+
const name = typeof body["name"] === "string" ? body["name"].trim() : "";
|
|
9762
|
+
if (!name) return c.json({ errors: ["name is required"] }, 400);
|
|
9763
|
+
const { data, error: error2 } = await getSupabaseClient().from("categories").update({ name, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).eq("profile", getProfile()).select().single();
|
|
9764
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9765
|
+
return c.json(data);
|
|
10031
9766
|
});
|
|
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);
|
|
9767
|
+
app.delete("/:id", async (c) => {
|
|
9768
|
+
const id = c.req.param("id");
|
|
10042
9769
|
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);
|
|
9770
|
+
const { error: unsetError } = await supabase.from("channels").update({ category_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("category_id", id);
|
|
9771
|
+
if (unsetError) return c.json({ error: unsetError.message }, 500);
|
|
9772
|
+
await refreshChannelRegistry();
|
|
9773
|
+
const { error: error2 } = await supabase.from("categories").delete().eq("id", id).eq("profile", getProfile());
|
|
9774
|
+
if (error2) return c.json({ error: error2.message }, 404);
|
|
9775
|
+
return c.json({ ok: true });
|
|
10173
9776
|
});
|
|
10174
9777
|
return app;
|
|
10175
9778
|
}
|
|
10176
9779
|
|
|
10177
|
-
// ../api/src/dashboard/
|
|
9780
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10178
9781
|
import { Hono as Hono13 } from "hono";
|
|
10179
9782
|
|
|
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
|
-
}
|
|
9783
|
+
// ../api/src/dashboard/fork-summary.ts
|
|
9784
|
+
var GROQ_API_URL4 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9785
|
+
var SUMMARY_MODEL = "openai/gpt-oss-120b";
|
|
9786
|
+
var MAX_INPUT_CHARS2 = 24e3;
|
|
9787
|
+
var TITLE_MAX_CHARS = 80;
|
|
9788
|
+
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
9789
|
|
|
10236
|
-
|
|
10237
|
-
|
|
10238
|
-
|
|
9790
|
+
Rules:
|
|
9791
|
+
- 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.
|
|
9792
|
+
- 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.
|
|
9793
|
+
- 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.
|
|
9794
|
+
- 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.
|
|
9795
|
+
- Preserve essential code identifiers, file names, and URLs, but do not reproduce whole code blocks \u2014 describe what they do in one sentence instead.
|
|
9796
|
+
- No preamble like "Summary:" \u2014 start directly with the title line.`;
|
|
9797
|
+
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
9798
|
|
|
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
|
-
}
|
|
9799
|
+
Output ONE line only: the title. Nothing else \u2014 no summary, no explanation, no blank line.
|
|
10255
9800
|
|
|
10256
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
}
|
|
9801
|
+
Shape:
|
|
9802
|
+
- A short noun phrase naming the subject, like a filed ticket's title. Max ${TITLE_MAX_CHARS} characters, and shorter is better.
|
|
9803
|
+
- Not a sentence copied from the message, not a quote, not Markdown. No bold, no backticks, no quotes, no trailing punctuation.
|
|
9804
|
+
|
|
9805
|
+
Content:
|
|
9806
|
+
- 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.
|
|
9807
|
+
- 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.
|
|
9808
|
+
- 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.
|
|
9809
|
+
- 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.
|
|
9810
|
+
|
|
9811
|
+
Example \u2014 a message that works out why an admin could not sign in, and closes by suggesting turning email confirmation off:
|
|
9812
|
+
admin \u5E33\u865F\u88AB email \u9A57\u8B49\u4FE1\u5361\u4F4F`;
|
|
9813
|
+
function clampTitle(title) {
|
|
9814
|
+
return title.length > TITLE_MAX_CHARS ? `${title.slice(0, TITLE_MAX_CHARS - 1)}\u2026` : title;
|
|
10271
9815
|
}
|
|
10272
|
-
|
|
10273
|
-
|
|
10274
|
-
|
|
10275
|
-
return `${pick()}-${pick()}`;
|
|
9816
|
+
function deriveTitle(body) {
|
|
9817
|
+
const line = body.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "Forked message";
|
|
9818
|
+
return clampTitle(line);
|
|
10276
9819
|
}
|
|
10277
|
-
function
|
|
10278
|
-
const
|
|
10279
|
-
|
|
10280
|
-
|
|
9820
|
+
function cleanTitleLine(raw) {
|
|
9821
|
+
const line = raw.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
9822
|
+
return clampTitle(
|
|
9823
|
+
line.replace(/^#{1,6}\s*/, "").replace(/^[*_`"'\u201c\u2018\u300c\u300e]+|[*_`"'\u201d\u2019\u300d\u300f]+$/g, "").trim()
|
|
9824
|
+
);
|
|
10281
9825
|
}
|
|
10282
|
-
function
|
|
10283
|
-
return {
|
|
10284
|
-
id: String(r["id"]),
|
|
10285
|
-
userId: String(r["user_id"]),
|
|
10286
|
-
name: String(r["name"]),
|
|
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
|
-
};
|
|
9826
|
+
function fallbackTopic(body) {
|
|
9827
|
+
return { title: deriveTitle(body), body };
|
|
10293
9828
|
}
|
|
10294
|
-
function
|
|
9829
|
+
function parseTopic(raw, original) {
|
|
9830
|
+
const text = raw.trim();
|
|
9831
|
+
const breakAt = text.indexOf("\n");
|
|
9832
|
+
if (breakAt === -1) return { title: deriveTitle(text), body: text };
|
|
9833
|
+
const title = text.slice(0, breakAt).trim();
|
|
9834
|
+
const body = text.slice(breakAt + 1).trim();
|
|
9835
|
+
if (!title || !body) return fallbackTopic(original);
|
|
10295
9836
|
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"])
|
|
9837
|
+
title: clampTitle(title),
|
|
9838
|
+
body
|
|
10308
9839
|
};
|
|
10309
9840
|
}
|
|
10310
|
-
function
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
10314
|
-
const
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
9841
|
+
async function buildForkTopic(transcript, fallbackText, mode = "condense") {
|
|
9842
|
+
const input2 = transcript.length > MAX_INPUT_CHARS2 ? transcript.slice(-MAX_INPUT_CHARS2) : transcript;
|
|
9843
|
+
const fallback = fallbackText ?? transcript;
|
|
9844
|
+
const focus = mode === "focus";
|
|
9845
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
9846
|
+
if (!apiKey) return fallbackTopic(fallback);
|
|
9847
|
+
try {
|
|
9848
|
+
const res = await fetch(GROQ_API_URL4, {
|
|
9849
|
+
method: "POST",
|
|
9850
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
9851
|
+
body: JSON.stringify({
|
|
9852
|
+
model: SUMMARY_MODEL,
|
|
9853
|
+
temperature: 0.2,
|
|
9854
|
+
// A title has to sort the message's central claim from the next-step
|
|
9855
|
+
// paragraph it often ends with; 'low' reliably grabs the latter.
|
|
9856
|
+
reasoning_effort: focus ? "medium" : "low",
|
|
9857
|
+
// A title is one short line, but focus mode reasons before answering
|
|
9858
|
+
// and the budget covers reasoning tokens too — too small a cap here
|
|
9859
|
+
// truncates the title mid-word rather than failing.
|
|
9860
|
+
max_tokens: focus ? 512 : 1024,
|
|
9861
|
+
messages: [
|
|
9862
|
+
{
|
|
9863
|
+
role: "system",
|
|
9864
|
+
content: focus ? FOCUS_TITLE_SYSTEM_PROMPT : CONDENSE_SYSTEM_PROMPT
|
|
9865
|
+
},
|
|
9866
|
+
{ role: "user", content: input2 }
|
|
9867
|
+
]
|
|
9868
|
+
})
|
|
10327
9869
|
});
|
|
10328
|
-
if (!
|
|
10329
|
-
|
|
9870
|
+
if (!res.ok) throw new Error(`Groq request failed (${res.status})`);
|
|
9871
|
+
const data = await res.json();
|
|
9872
|
+
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
9873
|
+
if (!raw) throw new Error("Groq returned an empty summary");
|
|
9874
|
+
if (focus) return { title: cleanTitleLine(raw) || deriveTitle(fallback), body: fallback };
|
|
9875
|
+
return parseTopic(raw, fallback);
|
|
9876
|
+
} catch (err) {
|
|
9877
|
+
console.error("Fork summary failed, using original text:", err);
|
|
9878
|
+
return fallbackTopic(fallback);
|
|
10330
9879
|
}
|
|
10331
|
-
throw new Error("createDeviceCode: could not allocate a unique user code");
|
|
10332
9880
|
}
|
|
10333
|
-
|
|
10334
|
-
|
|
10335
|
-
|
|
10336
|
-
|
|
9881
|
+
|
|
9882
|
+
// ../api/src/dashboard/slack-format.ts
|
|
9883
|
+
import { nameToEmoji } from "gemoji";
|
|
9884
|
+
var SLACK_EMOJI_ALIASES = {
|
|
9885
|
+
double_vertical_bar: "\u23F8\uFE0F"
|
|
9886
|
+
};
|
|
9887
|
+
function emojifyShortcode(code) {
|
|
9888
|
+
const name = code.slice(1, -1);
|
|
9889
|
+
return nameToEmoji[name] ?? SLACK_EMOJI_ALIASES[name] ?? code;
|
|
10337
9890
|
}
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
if (error2) fail("getDeviceCodeByUserCode", error2);
|
|
10341
|
-
return data ? toPending(data) : null;
|
|
9891
|
+
function resolveSlackTokens(text) {
|
|
9892
|
+
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
9893
|
}
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
if (error2) fail("markPolled", error2);
|
|
9894
|
+
function slackMrkdwnToMarkdown(text) {
|
|
9895
|
+
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
9896
|
}
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
9897
|
+
|
|
9898
|
+
// ../api/src/dashboard/thread-relevance.ts
|
|
9899
|
+
async function relevantThreadsPage(supabase, handle, channelIds, opts) {
|
|
9900
|
+
const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9901
|
+
const { data, error: error2 } = await supabase.rpc("relevant_threads", {
|
|
9902
|
+
handle,
|
|
9903
|
+
mention_pattern: `@${escaped}\\M`,
|
|
9904
|
+
channel_ids: channelIds,
|
|
9905
|
+
before: opts.before ?? null,
|
|
9906
|
+
page_limit: opts.limit
|
|
9907
|
+
});
|
|
9908
|
+
if (error2) throw new Error(`relevant_threads: ${error2.message}`);
|
|
9909
|
+
return data ?? [];
|
|
10353
9910
|
}
|
|
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);
|
|
9911
|
+
|
|
9912
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9913
|
+
import { createHash as createHash8 } from "crypto";
|
|
9914
|
+
|
|
9915
|
+
// ../api/src/store/thread-translation-store.ts
|
|
9916
|
+
async function getThreadTranslations(lang, keys) {
|
|
9917
|
+
const found = /* @__PURE__ */ new Map();
|
|
9918
|
+
if (keys.length === 0) return found;
|
|
9919
|
+
const clauses = keys.map((k) => `and(reply_id.eq.${k.replyId},body_hash.eq.${k.bodyHash})`).join(",");
|
|
9920
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_reply_translations").select("reply_id, body, passthrough").eq("lang", lang).or(clauses);
|
|
9921
|
+
if (error2) throw new Error(`getThreadTranslations(${lang}): ${error2.message}`);
|
|
9922
|
+
for (const row of data ?? []) {
|
|
9923
|
+
const replyId = row["reply_id"];
|
|
9924
|
+
found.set(replyId, {
|
|
9925
|
+
replyId,
|
|
9926
|
+
body: row["body"],
|
|
9927
|
+
passthrough: row["passthrough"]
|
|
9928
|
+
});
|
|
10378
9929
|
}
|
|
10379
|
-
return
|
|
9930
|
+
return found;
|
|
10380
9931
|
}
|
|
10381
|
-
async function
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
9932
|
+
async function putThreadTranslations(lang, rows) {
|
|
9933
|
+
if (rows.length === 0) return;
|
|
9934
|
+
const { error: error2 } = await getSupabaseClient().from("thread_reply_translations").upsert(
|
|
9935
|
+
rows.map((r) => ({
|
|
9936
|
+
reply_id: r.replyId,
|
|
9937
|
+
lang,
|
|
9938
|
+
body_hash: r.bodyHash,
|
|
9939
|
+
body: r.body,
|
|
9940
|
+
passthrough: r.passthrough
|
|
9941
|
+
})),
|
|
9942
|
+
{ onConflict: "reply_id,lang,body_hash" }
|
|
9943
|
+
);
|
|
9944
|
+
if (error2) throw new Error(`putThreadTranslations(${lang}): ${error2.message}`);
|
|
10385
9945
|
}
|
|
10386
|
-
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
9946
|
+
|
|
9947
|
+
// ../api/src/dashboard/thread-translate.ts
|
|
9948
|
+
var GROQ_API_URL5 = "https://api.groq.com/openai/v1/chat/completions";
|
|
9949
|
+
var TRANSLATION_MODEL2 = "openai/gpt-oss-120b";
|
|
9950
|
+
var RECIPE_VERSION3 = 1;
|
|
9951
|
+
var BATCH_CHAR_BUDGET = 16e3;
|
|
9952
|
+
var CONTEXT_MESSAGES = 4;
|
|
9953
|
+
var MAX_MESSAGE_CHARS = 12e3;
|
|
9954
|
+
var MAX_OUTPUT_TOKENS2 = 32768;
|
|
9955
|
+
var BY_CODE2 = new Map(TRANSLATION_LANGUAGES.map((l) => [l.code, l]));
|
|
9956
|
+
function isTranslatableLanguage2(lang) {
|
|
9957
|
+
return BY_CODE2.has(lang);
|
|
10390
9958
|
}
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
access_token_expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
10396
|
-
last_seen_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
10397
|
-
}).eq("id", deviceId);
|
|
10398
|
-
if (error2) fail("issueAccessToken", error2);
|
|
10399
|
-
return { token, expiresInS: ACCESS_TOKEN_TTL_MS / 1e3 };
|
|
9959
|
+
function translationHash2(author, body) {
|
|
9960
|
+
return createHash8("sha256").update(`${RECIPE_VERSION3}
|
|
9961
|
+
${author}
|
|
9962
|
+
${body}`).digest("hex");
|
|
10400
9963
|
}
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
9964
|
+
function systemPrompt2(languageName2) {
|
|
9965
|
+
return `You translate messages from a group chat into ${languageName2}. You are a translator, not an editor, a summarizer, or a participant.
|
|
9966
|
+
|
|
9967
|
+
You receive a JSON object with two keys:
|
|
9968
|
+
- "context": earlier messages, for reference only. NEVER translate these and never mention them in your output.
|
|
9969
|
+
- "translate": the messages to translate, each with an "n" and a "body".
|
|
9970
|
+
|
|
9971
|
+
Reply with ONLY a JSON object of this exact shape, one entry per message in "translate", in the same order:
|
|
9972
|
+
{"messages":[{"n":1,"body":"...","already_target":false}]}
|
|
9973
|
+
|
|
9974
|
+
Rules:
|
|
9975
|
+
- "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.
|
|
9976
|
+
- 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.
|
|
9977
|
+
- 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.
|
|
9978
|
+
- 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.
|
|
9979
|
+
- 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.
|
|
9980
|
+
- Keep established technical terms in English where a fluent speaker of ${languageName2} would keep them in English rather than reaching for a translated coinage.
|
|
9981
|
+
- 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.
|
|
9982
|
+
- Never add commentary, notes about your choices, apologies, or an explanation of anything you could not translate.`;
|
|
10406
9983
|
}
|
|
10407
|
-
|
|
10408
|
-
|
|
10409
|
-
if (error2) fail("revokeDevice", error2);
|
|
9984
|
+
function normalizeHyphens2(text) {
|
|
9985
|
+
return text.replace(/‐|‑/g, "-");
|
|
10410
9986
|
}
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
const
|
|
10416
|
-
|
|
10417
|
-
|
|
10418
|
-
|
|
9987
|
+
function clip(body) {
|
|
9988
|
+
return body.length > MAX_MESSAGE_CHARS ? body.slice(0, MAX_MESSAGE_CHARS) : body;
|
|
9989
|
+
}
|
|
9990
|
+
function batchMessages(messages) {
|
|
9991
|
+
const batches = [];
|
|
9992
|
+
let current = [];
|
|
9993
|
+
let size = 0;
|
|
9994
|
+
for (const m of messages) {
|
|
9995
|
+
const len = Math.min(m.body.length, MAX_MESSAGE_CHARS);
|
|
9996
|
+
if (current.length > 0 && size + len > BATCH_CHAR_BUDGET) {
|
|
9997
|
+
batches.push(current);
|
|
9998
|
+
current = [];
|
|
9999
|
+
size = 0;
|
|
10000
|
+
}
|
|
10001
|
+
current.push(m);
|
|
10002
|
+
size += len;
|
|
10003
|
+
}
|
|
10004
|
+
if (current.length > 0) batches.push(current);
|
|
10005
|
+
return batches;
|
|
10006
|
+
}
|
|
10007
|
+
async function translateBatch(batch, context, lang) {
|
|
10008
|
+
const language = BY_CODE2.get(lang);
|
|
10009
|
+
if (!language) throw new Error(`unsupported translation language: ${lang}`);
|
|
10010
|
+
const apiKey = process.env["GROQ_API_KEY"];
|
|
10011
|
+
if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
|
|
10012
|
+
const payload = {
|
|
10013
|
+
context: context.map((m) => ({ author: m.author, body: clip(m.body) })),
|
|
10014
|
+
translate: batch.map((m, i) => ({ n: i + 1, author: m.author, body: clip(m.body) }))
|
|
10015
|
+
};
|
|
10016
|
+
const res = await fetch(GROQ_API_URL5, {
|
|
10017
|
+
method: "POST",
|
|
10018
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10019
|
+
body: JSON.stringify({
|
|
10020
|
+
model: TRANSLATION_MODEL2,
|
|
10021
|
+
temperature: 0,
|
|
10022
|
+
max_tokens: MAX_OUTPUT_TOKENS2,
|
|
10023
|
+
response_format: { type: "json_object" },
|
|
10024
|
+
messages: [
|
|
10025
|
+
{ role: "system", content: systemPrompt2(language.name) },
|
|
10026
|
+
{ role: "user", content: JSON.stringify(payload) }
|
|
10027
|
+
]
|
|
10028
|
+
})
|
|
10029
|
+
});
|
|
10030
|
+
if (!res.ok) throw new Error(`translation model returned ${res.status}`);
|
|
10031
|
+
const data = await res.json();
|
|
10032
|
+
const choice = data.choices?.[0];
|
|
10033
|
+
if (choice?.finish_reason === "length") {
|
|
10034
|
+
throw new Error("the translation ran past the model output limit");
|
|
10035
|
+
}
|
|
10036
|
+
const raw = choice?.message?.content?.trim() ?? "";
|
|
10037
|
+
if (!raw) throw new Error("the model returned an empty translation");
|
|
10038
|
+
let parsed;
|
|
10039
|
+
try {
|
|
10040
|
+
parsed = JSON.parse(raw);
|
|
10041
|
+
} catch {
|
|
10042
|
+
throw new Error("the model returned unparseable JSON");
|
|
10043
|
+
}
|
|
10044
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
10045
|
+
for (const entry of parsed.messages ?? []) {
|
|
10046
|
+
if (typeof entry?.n === "number") byNumber.set(entry.n, entry);
|
|
10047
|
+
}
|
|
10048
|
+
return batch.map((m, i) => {
|
|
10049
|
+
const entry = byNumber.get(i + 1);
|
|
10050
|
+
const body = typeof entry?.body === "string" ? entry.body.trim() : "";
|
|
10051
|
+
if (!body) return { id: m.id, body: m.body, passthrough: true };
|
|
10052
|
+
return {
|
|
10053
|
+
id: m.id,
|
|
10054
|
+
body: normalizeHyphens2(body),
|
|
10055
|
+
passthrough: entry?.already_target === true
|
|
10056
|
+
};
|
|
10057
|
+
});
|
|
10058
|
+
}
|
|
10059
|
+
var inFlight4 = /* @__PURE__ */ new Map();
|
|
10060
|
+
async function translateThread(threadId, lang, messages) {
|
|
10061
|
+
if (!BY_CODE2.has(lang)) throw new Error(`unsupported translation language: ${lang}`);
|
|
10062
|
+
if (messages.length === 0) return [];
|
|
10063
|
+
const shape = createHash8("sha256").update(messages.map((m) => translationHash2(m.author, m.body)).join("\n")).digest("hex");
|
|
10064
|
+
const key = `${threadId}:${lang}:${shape}`;
|
|
10065
|
+
const running2 = inFlight4.get(key);
|
|
10066
|
+
if (running2) return running2;
|
|
10067
|
+
const job = (async () => {
|
|
10068
|
+
const hashes = messages.map((m) => ({
|
|
10069
|
+
replyId: m.id,
|
|
10070
|
+
bodyHash: translationHash2(m.author, m.body)
|
|
10071
|
+
}));
|
|
10072
|
+
const cached = await getThreadTranslations(lang, hashes);
|
|
10073
|
+
const missing = messages.filter((m) => !cached.has(m.id));
|
|
10074
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
10075
|
+
if (missing.length > 0) {
|
|
10076
|
+
const index = new Map(messages.map((m, i) => [m.id, i]));
|
|
10077
|
+
const batches = batchMessages(missing);
|
|
10078
|
+
const results = await Promise.all(
|
|
10079
|
+
batches.map((batch) => {
|
|
10080
|
+
const first = index.get(batch[0].id) ?? 0;
|
|
10081
|
+
const context = messages.slice(Math.max(0, first - CONTEXT_MESSAGES), first);
|
|
10082
|
+
return translateBatch(batch, context, lang);
|
|
10083
|
+
})
|
|
10084
|
+
);
|
|
10085
|
+
for (const row of results.flat()) fresh.set(row.id, row);
|
|
10086
|
+
const hashById = new Map(hashes.map((h) => [h.replyId, h.bodyHash]));
|
|
10087
|
+
await putThreadTranslations(
|
|
10088
|
+
lang,
|
|
10089
|
+
[...fresh.values()].map((r) => ({
|
|
10090
|
+
replyId: r.id,
|
|
10091
|
+
bodyHash: hashById.get(r.id),
|
|
10092
|
+
body: r.body,
|
|
10093
|
+
passthrough: r.passthrough
|
|
10094
|
+
}))
|
|
10095
|
+
);
|
|
10096
|
+
}
|
|
10097
|
+
return messages.map((m) => {
|
|
10098
|
+
const hit = cached.get(m.id);
|
|
10099
|
+
if (hit) return { id: m.id, body: hit.body, passthrough: hit.passthrough };
|
|
10100
|
+
return fresh.get(m.id) ?? { id: m.id, body: m.body, passthrough: true };
|
|
10101
|
+
});
|
|
10102
|
+
})().finally(() => inFlight4.delete(key));
|
|
10103
|
+
inFlight4.set(key, job);
|
|
10104
|
+
return job;
|
|
10105
|
+
}
|
|
10106
|
+
|
|
10107
|
+
// ../api/src/dashboard/chat-routes.ts
|
|
10108
|
+
function isNonEmptyString(v) {
|
|
10109
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
10110
|
+
}
|
|
10111
|
+
function sanitizeModel(v) {
|
|
10112
|
+
if (typeof v !== "string") return null;
|
|
10113
|
+
const model = v.trim().toLowerCase();
|
|
10114
|
+
return isAllowedModel(model) ? model : null;
|
|
10115
|
+
}
|
|
10116
|
+
async function threadModel(threadId) {
|
|
10117
|
+
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();
|
|
10118
|
+
return sanitizeModel(data?.model);
|
|
10119
|
+
}
|
|
10120
|
+
function requestedModel(c, model) {
|
|
10121
|
+
return c.get("auth")?.role === "admin" ? sanitizeModel(model) : null;
|
|
10122
|
+
}
|
|
10123
|
+
function sanitizeEffort(v) {
|
|
10124
|
+
if (typeof v !== "string") return null;
|
|
10125
|
+
const effort = v.trim().toLowerCase();
|
|
10126
|
+
return isEffort(effort) ? effort : null;
|
|
10127
|
+
}
|
|
10128
|
+
function requestedEffort(c, effort) {
|
|
10129
|
+
return c.get("auth")?.role === "admin" ? sanitizeEffort(effort) : null;
|
|
10130
|
+
}
|
|
10131
|
+
function effortFromRow(v) {
|
|
10132
|
+
return typeof v === "string" && isEffort(v) ? v : void 0;
|
|
10133
|
+
}
|
|
10134
|
+
var BOARD_STATUSES = [
|
|
10135
|
+
"backlog",
|
|
10136
|
+
"todo",
|
|
10137
|
+
"in_progress",
|
|
10138
|
+
"in_review",
|
|
10139
|
+
"done",
|
|
10140
|
+
"cancelled"
|
|
10141
|
+
];
|
|
10142
|
+
var NO_STATUS_COLUMN = "none";
|
|
10143
|
+
function boardColumnParam(status) {
|
|
10144
|
+
if (status === NO_STATUS_COLUMN) return NO_STATUS_COLUMN;
|
|
10145
|
+
return BOARD_STATUSES.includes(status) ? status : null;
|
|
10146
|
+
}
|
|
10147
|
+
var THREAD_CHUNK_SIZE = 25;
|
|
10148
|
+
var THREADS_PAGE_SIZE = 30;
|
|
10149
|
+
var THREADS_PAGE_SIZE_MAX = 100;
|
|
10150
|
+
var REPLIES_PAGE_SIZE = 50;
|
|
10151
|
+
var REPLIES_PAGE_SIZE_MAX = 200;
|
|
10152
|
+
var TRANSLATABLE_REPLIES_MAX = 400;
|
|
10153
|
+
var BOARD_PAGE_SIZE = 50;
|
|
10154
|
+
var BOARD_PAGE_SIZE_MAX = 100;
|
|
10155
|
+
function ownChannelIds(c) {
|
|
10156
|
+
const viewer = c.get("auth");
|
|
10157
|
+
return new Set(
|
|
10158
|
+
getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, viewer)).flatMap((ch) => ch.externalChannelId ? [ch.id, ch.externalChannelId] : [ch.id])
|
|
10159
|
+
);
|
|
10160
|
+
}
|
|
10161
|
+
async function globalChannelMeta(c) {
|
|
10162
|
+
const viewer = c.get("auth");
|
|
10163
|
+
const map = /* @__PURE__ */ new Map();
|
|
10164
|
+
const channels = getChannelRegistry();
|
|
10165
|
+
let workspaceIdBySlug = /* @__PURE__ */ new Map();
|
|
10166
|
+
let roleByWorkspaceId = /* @__PURE__ */ new Map();
|
|
10167
|
+
if (viewer?.email && channels.length > 0) {
|
|
10168
|
+
const [workspaces, memberships] = await Promise.all([
|
|
10169
|
+
listWorkspaces(),
|
|
10170
|
+
listMembershipsByEmail(viewer.email)
|
|
10171
|
+
]);
|
|
10172
|
+
workspaceIdBySlug = new Map(workspaces.map((w) => [w.slug, w.id]));
|
|
10173
|
+
roleByWorkspaceId = new Map(memberships.map((m) => [m.workspaceId, m.role]));
|
|
10174
|
+
}
|
|
10175
|
+
for (const ch of channels) {
|
|
10176
|
+
const workspaceId = workspaceIdBySlug.get(ch.profile);
|
|
10177
|
+
const role = workspaceId ? roleByWorkspaceId.get(workspaceId) : void 0;
|
|
10178
|
+
if (!canSeeChannel(ch, { email: viewer?.email, role })) continue;
|
|
10179
|
+
const meta = { workspace: ch.profile, channelName: ch.name, agentName: ch.agentName };
|
|
10180
|
+
map.set(ch.id, meta);
|
|
10181
|
+
if (ch.externalChannelId) map.set(ch.externalChannelId, meta);
|
|
10182
|
+
}
|
|
10183
|
+
return map;
|
|
10184
|
+
}
|
|
10185
|
+
async function threadIsKnown(threadId, c) {
|
|
10186
|
+
const { data } = await getSupabaseClient().from("threads").select("channel_id").eq("id", threadId).single();
|
|
10187
|
+
const channelId = data?.channel_id;
|
|
10188
|
+
return !!channelId && (await globalChannelMeta(c)).has(channelId);
|
|
10189
|
+
}
|
|
10190
|
+
function chunk(items, size) {
|
|
10191
|
+
const out = [];
|
|
10192
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
10193
|
+
return out;
|
|
10194
|
+
}
|
|
10195
|
+
async function withPreviews(supabase, threads) {
|
|
10196
|
+
const chunkResults = await Promise.all(
|
|
10197
|
+
chunk(
|
|
10198
|
+
threads.map((t) => t.id),
|
|
10199
|
+
THREAD_CHUNK_SIZE
|
|
10200
|
+
).map(
|
|
10201
|
+
(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 })
|
|
10202
|
+
)
|
|
10203
|
+
);
|
|
10204
|
+
const previewByThread = /* @__PURE__ */ new Map();
|
|
10205
|
+
for (const { data } of chunkResults) {
|
|
10206
|
+
for (const r of data ?? []) {
|
|
10207
|
+
const entry = previewByThread.get(r.thread_id);
|
|
10208
|
+
if (entry) {
|
|
10209
|
+
entry.count += 1;
|
|
10210
|
+
entry.lastAuthor = r.author;
|
|
10211
|
+
entry.participants.delete(r.author);
|
|
10212
|
+
entry.participants.set(r.author, r.external_user_id ?? null);
|
|
10213
|
+
} else
|
|
10214
|
+
previewByThread.set(r.thread_id, {
|
|
10215
|
+
count: 1,
|
|
10216
|
+
firstBody: r.body,
|
|
10217
|
+
lastAuthor: r.author,
|
|
10218
|
+
participants: /* @__PURE__ */ new Map()
|
|
10219
|
+
});
|
|
10220
|
+
}
|
|
10221
|
+
}
|
|
10222
|
+
return threads.map((t) => {
|
|
10223
|
+
const entry = previewByThread.get(t.id);
|
|
10224
|
+
return {
|
|
10225
|
+
...t,
|
|
10226
|
+
reply_count: Math.max(0, (entry?.count ?? 0) - 1),
|
|
10227
|
+
preview: entry?.firstBody ? slackMrkdwnToMarkdown(entry.firstBody) : null,
|
|
10228
|
+
// Who spoke last, counting only replies AFTER the opening message —
|
|
10229
|
+
// the reader's own handle here means there is nothing new for them to
|
|
10230
|
+
// read, on this browser or any other (see web/src/lib/thread-unread).
|
|
10231
|
+
last_reply_author: entry && entry.count > 1 ? entry.lastAuthor : null,
|
|
10232
|
+
// Everyone who replied, most recent first, for the row's avatar stack.
|
|
10233
|
+
participants: [...entry?.participants ?? []].reverse().map(([author, external_user_id]) => ({ author, external_user_id }))
|
|
10234
|
+
};
|
|
10235
|
+
});
|
|
10236
|
+
}
|
|
10237
|
+
async function labelThread(channelId, body, scope) {
|
|
10238
|
+
const channel = getProfileChannelRegistry().find(
|
|
10239
|
+
(ch) => ch.id === channelId || ch.externalChannelId === channelId
|
|
10240
|
+
);
|
|
10241
|
+
if (!channel) return null;
|
|
10242
|
+
const supabase = getSupabaseClient();
|
|
10243
|
+
const { data: recent } = await supabase.from("threads").select("auto_title, title").eq("channel_id", channelId).order("created_at", { ascending: false }).limit(50);
|
|
10244
|
+
const existingCategories = extractCategories(
|
|
10245
|
+
(recent ?? []).flatMap((t) => [
|
|
10246
|
+
t.auto_title,
|
|
10247
|
+
t.title
|
|
10248
|
+
])
|
|
10249
|
+
);
|
|
10250
|
+
return buildAutoTitle({
|
|
10251
|
+
channelName: channel.name,
|
|
10252
|
+
channelPrompt: channel.prompt,
|
|
10253
|
+
workspacePrompt: loadWorkspacePrompt().prompt,
|
|
10254
|
+
titlePrompt: (await loadWorkspaceBranding()).autoTitlePrompt,
|
|
10255
|
+
existingCategories,
|
|
10256
|
+
body,
|
|
10257
|
+
scope
|
|
10258
|
+
});
|
|
10259
|
+
}
|
|
10260
|
+
async function generateAutoTitle(threadId, channelId, body) {
|
|
10261
|
+
if ((await loadWorkspaceBranding()).autoTitleEnabled === false) return;
|
|
10262
|
+
const autoTitle = await labelThread(channelId, body);
|
|
10263
|
+
if (!autoTitle) return;
|
|
10264
|
+
const supabase = getSupabaseClient();
|
|
10265
|
+
await supabase.from("threads").update({ auto_title: autoTitle }).eq("id", threadId).is("title", null);
|
|
10266
|
+
}
|
|
10267
|
+
function createChatRouter(agent = {}) {
|
|
10268
|
+
const app = new Hono13();
|
|
10269
|
+
const {
|
|
10270
|
+
onChatMessage: onMessage,
|
|
10271
|
+
isAgentThinking,
|
|
10272
|
+
getAgentActivities,
|
|
10273
|
+
cancelAgentRun,
|
|
10274
|
+
regenerateAgentRun,
|
|
10275
|
+
listAgentRuns
|
|
10276
|
+
} = agent;
|
|
10277
|
+
app.post(
|
|
10278
|
+
"/threads/:id/agent-cancel",
|
|
10279
|
+
async (c) => c.json({ cancelled: await cancelAgentRun?.(c.req.param("id")) ?? false })
|
|
10280
|
+
);
|
|
10281
|
+
app.post(
|
|
10282
|
+
"/threads/:id/agent-regenerate",
|
|
10283
|
+
async (c) => c.json({ started: await regenerateAgentRun?.(c.req.param("id")) ?? false })
|
|
10284
|
+
);
|
|
10285
|
+
app.get(
|
|
10286
|
+
"/threads/:id/agent-status",
|
|
10287
|
+
async (c) => c.json({
|
|
10288
|
+
thinking: await isAgentThinking?.(c.req.param("id")) ?? false,
|
|
10289
|
+
activities: await getAgentActivities?.(c.req.param("id")) ?? []
|
|
10290
|
+
})
|
|
10291
|
+
);
|
|
10292
|
+
app.get("/agent-runs", async (c) => c.json({ threadIds: await listAgentRuns?.() ?? [] }));
|
|
10293
|
+
app.get("/channels/:channelId/threads", async (c) => {
|
|
10294
|
+
const channelId = c.req.param("channelId");
|
|
10295
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10296
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10297
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10298
|
+
const before = c.req.query("before");
|
|
10299
|
+
const sortColumn = c.req.query("sort") === "activity" ? "updated_at" : "created_at";
|
|
10300
|
+
const statusParam = c.req.query("status");
|
|
10301
|
+
const statusFilter = statusParam ? [
|
|
10302
|
+
...new Set(
|
|
10303
|
+
statusParam.split(",").map(boardColumnParam).filter((s) => s !== null)
|
|
10304
|
+
)
|
|
10305
|
+
] : [];
|
|
10306
|
+
const supabase = getSupabaseClient();
|
|
10307
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId).is("archived_at", null).order(sortColumn, { ascending: false }).limit(limit);
|
|
10308
|
+
if (before) query = query.lt(sortColumn, before);
|
|
10309
|
+
if (statusFilter.length) {
|
|
10310
|
+
const withoutNone = statusFilter.filter((s) => s !== NO_STATUS_COLUMN);
|
|
10311
|
+
const includeNone = statusFilter.includes(NO_STATUS_COLUMN);
|
|
10312
|
+
if (includeNone && withoutNone.length) {
|
|
10313
|
+
query = query.or(`board_status.in.(${withoutNone.join(",")}),board_status.is.null`);
|
|
10314
|
+
} else if (includeNone) {
|
|
10315
|
+
query = query.is("board_status", null);
|
|
10316
|
+
} else {
|
|
10317
|
+
query = query.in("board_status", withoutNone);
|
|
10318
|
+
}
|
|
10319
|
+
}
|
|
10320
|
+
const { data: threads, error: error2 } = await query;
|
|
10321
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10322
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10323
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10324
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1][sortColumn] : null;
|
|
10325
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10326
|
+
});
|
|
10327
|
+
app.get("/channels/:channelId/board/:status", async (c) => {
|
|
10328
|
+
const channelId = c.req.param("channelId");
|
|
10329
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10330
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10331
|
+
if (!column) {
|
|
10332
|
+
return c.json(
|
|
10333
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10334
|
+
400
|
|
10335
|
+
);
|
|
10336
|
+
}
|
|
10337
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10338
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10339
|
+
const before = c.req.query("before");
|
|
10340
|
+
const supabase = getSupabaseClient();
|
|
10341
|
+
let query = supabase.from("threads").select("*").eq("channel_id", channelId);
|
|
10342
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10343
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10344
|
+
if (before) query = query.lt("updated_at", before);
|
|
10345
|
+
const { data: threads, error: error2 } = await query;
|
|
10346
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10347
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10348
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10349
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10350
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10351
|
+
});
|
|
10352
|
+
app.get("/board/:status", async (c) => {
|
|
10353
|
+
const column = boardColumnParam(c.req.param("status"));
|
|
10354
|
+
if (!column) {
|
|
10355
|
+
return c.json(
|
|
10356
|
+
{ error: `status must be one of: ${[...BOARD_STATUSES, NO_STATUS_COLUMN].join(", ")}` },
|
|
10357
|
+
400
|
|
10358
|
+
);
|
|
10359
|
+
}
|
|
10360
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10361
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, BOARD_PAGE_SIZE_MAX) : BOARD_PAGE_SIZE;
|
|
10362
|
+
const before = c.req.query("before");
|
|
10363
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10364
|
+
const supabase = getSupabaseClient();
|
|
10365
|
+
let query = supabase.from("threads").select("*").in("channel_id", meta ? [...meta.keys()] : [...ownChannelIds(c)]);
|
|
10366
|
+
query = column === NO_STATUS_COLUMN ? query.is("board_status", null) : query.eq("board_status", column);
|
|
10367
|
+
query = query.is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10368
|
+
if (before) query = query.lt("updated_at", before);
|
|
10369
|
+
const { data: threads, error: error2 } = await query;
|
|
10370
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10371
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10372
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10373
|
+
const labelled = meta ? withPreview.map((t) => {
|
|
10374
|
+
const channelMeta = meta.get(t.channel_id ?? "");
|
|
10375
|
+
return {
|
|
10376
|
+
...t,
|
|
10377
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10378
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10379
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10380
|
+
};
|
|
10381
|
+
}) : withPreview;
|
|
10382
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10383
|
+
return c.json({ threads: labelled, nextCursor });
|
|
10384
|
+
});
|
|
10385
|
+
app.get("/threads", async (c) => {
|
|
10386
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10387
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10388
|
+
const before = c.req.query("before");
|
|
10389
|
+
const meta = c.req.query("scope") === "all" ? await globalChannelMeta(c) : null;
|
|
10390
|
+
const relevantTo = c.req.query("relevantTo");
|
|
10391
|
+
const supabase = getSupabaseClient();
|
|
10392
|
+
const channelIds = meta ? [...meta.keys()] : [...ownChannelIds(c)];
|
|
10393
|
+
let threads;
|
|
10394
|
+
if (isNonEmptyString(relevantTo)) {
|
|
10395
|
+
try {
|
|
10396
|
+
threads = await relevantThreadsPage(supabase, relevantTo, channelIds, { before, limit });
|
|
10397
|
+
} catch (err) {
|
|
10398
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
10399
|
+
}
|
|
10400
|
+
} else {
|
|
10401
|
+
let query = supabase.from("threads").select("*").in("channel_id", channelIds).is("archived_at", null).order("updated_at", { ascending: false }).limit(limit);
|
|
10402
|
+
if (before) query = query.lt("updated_at", before);
|
|
10403
|
+
const { data, error: error2 } = await query;
|
|
10404
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10405
|
+
threads = data;
|
|
10406
|
+
}
|
|
10407
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10408
|
+
const threadIds = threads.map((t) => t.id);
|
|
10409
|
+
const chunkResults = await Promise.all(
|
|
10410
|
+
chunk(threadIds, THREAD_CHUNK_SIZE).map(
|
|
10411
|
+
(ids) => supabase.from("thread_replies").select("*").in("thread_id", ids).is("archived_at", null).order("created_at", { ascending: true })
|
|
10412
|
+
)
|
|
10413
|
+
);
|
|
10414
|
+
const repliesByThread = /* @__PURE__ */ new Map();
|
|
10415
|
+
for (const { data } of chunkResults) {
|
|
10416
|
+
for (const r of data ?? []) {
|
|
10417
|
+
const arr = repliesByThread.get(r.thread_id);
|
|
10418
|
+
if (arr) arr.push(r);
|
|
10419
|
+
else repliesByThread.set(r.thread_id, [r]);
|
|
10420
|
+
}
|
|
10421
|
+
}
|
|
10422
|
+
const withReplies = threads.map((t) => {
|
|
10423
|
+
const channelMeta = meta?.get(t.channel_id);
|
|
10424
|
+
return {
|
|
10425
|
+
...t,
|
|
10426
|
+
...meta ? {
|
|
10427
|
+
workspace: channelMeta?.workspace ?? null,
|
|
10428
|
+
channel_name: channelMeta?.channelName ?? null,
|
|
10429
|
+
agent_name: channelMeta?.agentName ?? null
|
|
10430
|
+
} : {},
|
|
10431
|
+
replies: (repliesByThread.get(t.id) ?? []).map((r) => ({
|
|
10432
|
+
...r,
|
|
10433
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10434
|
+
}))
|
|
10435
|
+
};
|
|
10436
|
+
});
|
|
10437
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].updated_at : null;
|
|
10438
|
+
return c.json({ threads: withReplies, nextCursor });
|
|
10439
|
+
});
|
|
10440
|
+
app.get("/threads/archived", async (c) => {
|
|
10441
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10442
|
+
return c.json({ error: "only an admin can view archived topics" }, 403);
|
|
10443
|
+
}
|
|
10444
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10445
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, THREADS_PAGE_SIZE_MAX) : THREADS_PAGE_SIZE;
|
|
10446
|
+
const before = c.req.query("before");
|
|
10447
|
+
const supabase = getSupabaseClient();
|
|
10448
|
+
let query = supabase.from("threads").select("*").in("channel_id", [...ownChannelIds(c)]).not("archived_at", "is", null).order("archived_at", { ascending: false }).limit(limit);
|
|
10449
|
+
if (before) query = query.lt("archived_at", before);
|
|
10450
|
+
const { data: threads, error: error2 } = await query;
|
|
10451
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10452
|
+
if (!threads || threads.length === 0) return c.json({ threads: [], nextCursor: null });
|
|
10453
|
+
const withPreview = await withPreviews(supabase, threads);
|
|
10454
|
+
const nextCursor = threads.length === limit ? threads[threads.length - 1].archived_at : null;
|
|
10455
|
+
return c.json({ threads: withPreview, nextCursor });
|
|
10456
|
+
});
|
|
10457
|
+
app.get("/threads/:id", async (c) => {
|
|
10458
|
+
const id = c.req.param("id");
|
|
10459
|
+
const requestedLimit = Number(c.req.query("limit"));
|
|
10460
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, REPLIES_PAGE_SIZE_MAX) : REPLIES_PAGE_SIZE;
|
|
10461
|
+
const before = c.req.query("before");
|
|
10462
|
+
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);
|
|
10463
|
+
if (before) repliesQuery = repliesQuery.lt("created_at", before);
|
|
10464
|
+
const [thread, replies, opening] = await Promise.all([
|
|
10465
|
+
getSupabaseClient().from("threads").select("*").eq("id", id).single(),
|
|
10466
|
+
repliesQuery,
|
|
10467
|
+
// The opening message, fetched separately because the replies page
|
|
10468
|
+
// above is newest-first: on a long thread the opener isn't in it. It
|
|
10469
|
+
// is what the header falls back to on a thread with no title of its
|
|
10470
|
+
// own (see threadCrumbLabel) — a title is never written on the
|
|
10471
|
+
// thread's behalf, so an untitled row must still be able to show and
|
|
10472
|
+
// rename a topic.
|
|
10473
|
+
getSupabaseClient().from("thread_replies").select("body").eq("thread_id", id).is("archived_at", null).order("created_at", { ascending: true }).limit(1)
|
|
10474
|
+
]);
|
|
10475
|
+
if (thread.error) return c.json({ error: thread.error.message }, 404);
|
|
10476
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10477
|
+
const channelId = thread.data?.channel_id;
|
|
10478
|
+
const meta = await globalChannelMeta(c);
|
|
10479
|
+
const channelMeta = channelId ? meta.get(channelId) : void 0;
|
|
10480
|
+
if (!channelId || !channelMeta) {
|
|
10481
|
+
return c.json({ error: "not found" }, 404);
|
|
10482
|
+
}
|
|
10483
|
+
const page2 = replies.data ?? [];
|
|
10484
|
+
const nextCursor = page2.length === limit ? page2[page2.length - 1].created_at : null;
|
|
10485
|
+
const formattedReplies = page2.reverse().map((r) => ({
|
|
10486
|
+
...r,
|
|
10487
|
+
body: slackMrkdwnToMarkdown(r.body)
|
|
10488
|
+
}));
|
|
10489
|
+
const openingBody = (opening.data ?? [])[0]?.body;
|
|
10490
|
+
return c.json({
|
|
10491
|
+
...thread.data,
|
|
10492
|
+
// Always stamped (not just on `?scope=all` feeds, see the `/threads`
|
|
10493
|
+
// route above) — the reply pane can be opened directly by id (global
|
|
10494
|
+
// board, permalink) without the active workspace's channel map ever
|
|
10495
|
+
// having loaded this thread's channel, so the header needs its own
|
|
10496
|
+
// workspace/channel label rather than depending on that cache.
|
|
10497
|
+
workspace: channelMeta.workspace,
|
|
10498
|
+
channel_name: channelMeta.channelName,
|
|
10499
|
+
agent_name: channelMeta.agentName,
|
|
10500
|
+
preview: openingBody ? slackMrkdwnToMarkdown(openingBody) : null,
|
|
10501
|
+
replies: formattedReplies,
|
|
10502
|
+
nextCursor
|
|
10503
|
+
});
|
|
10504
|
+
});
|
|
10505
|
+
app.post("/threads/:id/translation/:lang", async (c) => {
|
|
10506
|
+
const id = c.req.param("id");
|
|
10507
|
+
const lang = canonicalLanguage(c.req.param("lang"));
|
|
10508
|
+
if (!isTranslatableLanguage2(lang)) return c.json({ error: "unsupported language" }, 400);
|
|
10509
|
+
const [thread, replies] = await Promise.all([
|
|
10510
|
+
getSupabaseClient().from("threads").select("channel_id").eq("id", id).single(),
|
|
10511
|
+
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)
|
|
10512
|
+
]);
|
|
10513
|
+
if (thread.error) return c.json({ error: "not found" }, 404);
|
|
10514
|
+
if (replies.error) return c.json({ error: replies.error.message }, 500);
|
|
10515
|
+
const channelId = thread.data?.channel_id;
|
|
10516
|
+
if (!channelId || !(await globalChannelMeta(c)).has(channelId)) {
|
|
10517
|
+
return c.json({ error: "not found" }, 404);
|
|
10518
|
+
}
|
|
10519
|
+
const messages = (replies.data ?? []).map(
|
|
10520
|
+
(r) => ({ id: r.id, author: r.author, body: slackMrkdwnToMarkdown(r.body) })
|
|
10521
|
+
);
|
|
10522
|
+
try {
|
|
10523
|
+
return c.json({ lang, messages: await translateThread(id, lang, messages) });
|
|
10524
|
+
} catch (err) {
|
|
10525
|
+
const message = err instanceof Error ? err.message : "translation failed";
|
|
10526
|
+
console.error(`Thread translation failed (${id} \u2192 ${lang}):`, err);
|
|
10527
|
+
return c.json({ error: message }, 502);
|
|
10528
|
+
}
|
|
10529
|
+
});
|
|
10530
|
+
app.patch("/threads/:id/board-status", async (c) => {
|
|
10531
|
+
const id = c.req.param("id");
|
|
10532
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10533
|
+
const value = input2?.board_status ?? null;
|
|
10534
|
+
if (value !== null && !BOARD_STATUSES.includes(value)) {
|
|
10535
|
+
return c.json(
|
|
10536
|
+
{ error: `board_status must be null or one of: ${BOARD_STATUSES.join(", ")}` },
|
|
10537
|
+
400
|
|
10538
|
+
);
|
|
10539
|
+
}
|
|
10540
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10541
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ board_status: value }).eq("id", id).select().single();
|
|
10542
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10543
|
+
return c.json(thread);
|
|
10544
|
+
});
|
|
10545
|
+
app.patch("/threads/:id", async (c) => {
|
|
10546
|
+
const id = c.req.param("id");
|
|
10547
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10548
|
+
const raw = input2?.title;
|
|
10549
|
+
if (raw !== null && !isNonEmptyString(raw)) {
|
|
10550
|
+
return c.json({ errors: ["title must be a non-empty string, or null to clear it"] }, 400);
|
|
10551
|
+
}
|
|
10552
|
+
const title = raw === null ? null : raw.trim();
|
|
10553
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10554
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ title }).eq("id", id).select().single();
|
|
10555
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10556
|
+
return c.json(thread);
|
|
10557
|
+
});
|
|
10558
|
+
app.post("/threads/:id/suggest-title", async (c) => {
|
|
10559
|
+
const id = c.req.param("id");
|
|
10560
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10561
|
+
const { data: thread } = await getSupabaseClient().from("threads").select("channel_id").eq("id", id).single();
|
|
10562
|
+
if (!thread) return c.json({ error: "not found" }, 404);
|
|
10563
|
+
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 });
|
|
10564
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10565
|
+
const transcript = (rows ?? []).map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10566
|
+
const title = await labelThread(thread.channel_id, transcript, "thread");
|
|
10567
|
+
if (!title) return c.json({ error: "could not generate a topic" }, 502);
|
|
10568
|
+
return c.json({ title });
|
|
10569
|
+
});
|
|
10570
|
+
app.patch("/replies/:replyId", async (c) => {
|
|
10571
|
+
const replyId = c.req.param("replyId");
|
|
10572
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10573
|
+
const author = input2?.author;
|
|
10574
|
+
const body = input2?.body;
|
|
10575
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(body)) {
|
|
10576
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10577
|
+
}
|
|
10578
|
+
const supabase = getSupabaseClient();
|
|
10579
|
+
const { data: existing, error: fetchError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10580
|
+
if (fetchError) return c.json({ error: fetchError.message }, 404);
|
|
10581
|
+
if (!await threadIsKnown(existing.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10582
|
+
if (existing.author !== author) {
|
|
10583
|
+
return c.json({ error: "only the author can edit a message" }, 403);
|
|
10584
|
+
}
|
|
10585
|
+
const { data: reply, error: error2 } = await supabase.from("thread_replies").update({ body, edited_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", replyId).select().single();
|
|
10586
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10587
|
+
if (isHumanAuthor(author)) {
|
|
10588
|
+
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();
|
|
10589
|
+
if (latest2?.id === replyId) {
|
|
10590
|
+
await Promise.resolve(regenerateAgentRun?.(existing.thread_id, replyId)).catch(() => {
|
|
10591
|
+
});
|
|
10592
|
+
}
|
|
10593
|
+
}
|
|
10594
|
+
return c.json(reply);
|
|
10595
|
+
});
|
|
10596
|
+
app.post("/replies/:replyId/reactions", async (c) => {
|
|
10597
|
+
const replyId = c.req.param("replyId");
|
|
10598
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10599
|
+
const author = input2?.author;
|
|
10600
|
+
const emoji = input2?.emoji;
|
|
10601
|
+
if (!isNonEmptyString(author) || !isNonEmptyString(emoji)) {
|
|
10602
|
+
return c.json({ errors: ["author and emoji are required"] }, 400);
|
|
10603
|
+
}
|
|
10604
|
+
const supabase = getSupabaseClient();
|
|
10605
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").select("thread_id").eq("id", replyId).single();
|
|
10606
|
+
if (replyError) return c.json({ error: "not found" }, 404);
|
|
10607
|
+
if (!await threadIsKnown(reply.thread_id, c)) return c.json({ error: "not found" }, 404);
|
|
10608
|
+
const { data: existing, error: existingError } = await supabase.from("reply_reactions").select("id").eq("reply_id", replyId).eq("emoji", emoji).eq("author", author).maybeSingle();
|
|
10609
|
+
if (existingError) return c.json({ error: existingError.message }, 500);
|
|
10610
|
+
if (existing) {
|
|
10611
|
+
const { error: error2 } = await supabase.from("reply_reactions").delete().eq("id", existing.id);
|
|
10612
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10613
|
+
} else {
|
|
10614
|
+
const { error: error2 } = await supabase.from("reply_reactions").insert({ reply_id: replyId, emoji, author });
|
|
10615
|
+
if (error2 && error2.code !== "23505") return c.json({ error: error2.message }, 500);
|
|
10616
|
+
}
|
|
10617
|
+
const { data: reactions, error: reactionsError } = await supabase.from("reply_reactions").select("emoji, author").eq("reply_id", replyId);
|
|
10618
|
+
if (reactionsError) return c.json({ error: reactionsError.message }, 500);
|
|
10619
|
+
return c.json({ reactions: reactions ?? [] });
|
|
10620
|
+
});
|
|
10621
|
+
app.patch("/threads/:id/archive", async (c) => {
|
|
10622
|
+
if (c.get("auth")?.role !== "admin") {
|
|
10623
|
+
return c.json({ error: "only an admin can archive a topic" }, 403);
|
|
10624
|
+
}
|
|
10625
|
+
const id = c.req.param("id");
|
|
10626
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10627
|
+
const archived = input2?.archived;
|
|
10628
|
+
if (typeof archived !== "boolean") {
|
|
10629
|
+
return c.json({ errors: ["archived (boolean) is required"] }, 400);
|
|
10630
|
+
}
|
|
10631
|
+
if (!await threadIsKnown(id, c)) return c.json({ error: "not found" }, 404);
|
|
10632
|
+
const { data: thread, error: error2 } = await getSupabaseClient().from("threads").update({ archived_at: archived ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", id).select().single();
|
|
10633
|
+
if (error2) return c.json({ error: error2.message }, 500);
|
|
10634
|
+
return c.json(thread);
|
|
10635
|
+
});
|
|
10636
|
+
app.post("/channels/:channelId/threads", async (c) => {
|
|
10637
|
+
const channelId = c.req.param("channelId");
|
|
10638
|
+
if (!ownChannelIds(c).has(channelId)) return c.json({ error: "unknown channel" }, 404);
|
|
10639
|
+
const input2 = await c.req.json();
|
|
10640
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10641
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10642
|
+
}
|
|
10643
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10644
|
+
const model = requestedModel(c, input2.model);
|
|
10645
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10646
|
+
const supabase = getSupabaseClient();
|
|
10647
|
+
let thread;
|
|
10648
|
+
let replayed = false;
|
|
10649
|
+
const { data: inserted, error: threadError } = await supabase.from("threads").insert({
|
|
10650
|
+
channel_id: channelId,
|
|
10651
|
+
// A stored title means someone named the topic. A blank one is not a
|
|
10652
|
+
// name, so it lands as null and the topic is derived from the
|
|
10653
|
+
// opening message at display time (see threadCrumbLabel).
|
|
10654
|
+
title: isNonEmptyString(input2.title) ? input2.title.trim() : null,
|
|
10655
|
+
author: input2.author,
|
|
10656
|
+
client_key: clientKey2
|
|
10657
|
+
}).select().single();
|
|
10658
|
+
if (threadError) {
|
|
10659
|
+
if (!clientKey2 || threadError.code !== "23505") {
|
|
10660
|
+
return c.json({ error: threadError.message }, 500);
|
|
10661
|
+
}
|
|
10662
|
+
const { data: existing } = await supabase.from("threads").select("*").eq("client_key", clientKey2).single();
|
|
10663
|
+
if (!existing) return c.json({ error: threadError.message }, 500);
|
|
10664
|
+
thread = existing;
|
|
10665
|
+
replayed = true;
|
|
10666
|
+
} else {
|
|
10667
|
+
thread = inserted;
|
|
10668
|
+
}
|
|
10669
|
+
let { data: reply } = replayed && clientKey2 ? await supabase.from("thread_replies").select("*").eq("client_key", clientKey2).single() : { data: null };
|
|
10670
|
+
let replyCompleted = false;
|
|
10671
|
+
if (!reply) {
|
|
10672
|
+
const { data: insertedReply, error: replyError } = await supabase.from("thread_replies").insert({
|
|
10673
|
+
thread_id: thread.id,
|
|
10674
|
+
author: input2.author,
|
|
10675
|
+
body: input2.body,
|
|
10676
|
+
client_key: clientKey2,
|
|
10677
|
+
model,
|
|
10678
|
+
effort
|
|
10679
|
+
}).select().single();
|
|
10680
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10681
|
+
reply = insertedReply;
|
|
10682
|
+
replyCompleted = true;
|
|
10683
|
+
}
|
|
10684
|
+
if (!replayed || replyCompleted) {
|
|
10685
|
+
onMessage?.({
|
|
10686
|
+
threadId: thread.id,
|
|
10687
|
+
replyId: reply.id,
|
|
10688
|
+
author: input2.author,
|
|
10689
|
+
body: input2.body,
|
|
10690
|
+
// From the row, not `model`/`effort`: on the replay path the reply
|
|
10691
|
+
// predates this request, so its own stored override is the source of
|
|
10692
|
+
// truth.
|
|
10693
|
+
model: reply["model"] ?? void 0,
|
|
10694
|
+
effort: effortFromRow(reply["effort"])
|
|
10695
|
+
});
|
|
10696
|
+
}
|
|
10697
|
+
if (!replayed && thread["title"] == null) {
|
|
10698
|
+
void generateAutoTitle(thread.id, channelId, input2.body).catch(
|
|
10699
|
+
(err) => console.error("Auto title failed:", err)
|
|
10700
|
+
);
|
|
10701
|
+
}
|
|
10702
|
+
return c.json({ ...thread, replies: [reply] }, replayed ? 200 : 201);
|
|
10419
10703
|
});
|
|
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);
|
|
10704
|
+
app.post("/replies/:replyId/fork", async (c) => {
|
|
10705
|
+
const replyId = c.req.param("replyId");
|
|
10706
|
+
const input2 = await c.req.json().catch(() => null);
|
|
10707
|
+
const author = input2?.author;
|
|
10708
|
+
if (!isNonEmptyString(author)) {
|
|
10709
|
+
return c.json({ errors: ["author is required"] }, 400);
|
|
10461
10710
|
}
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
}
|
|
10465
|
-
|
|
10466
|
-
|
|
10467
|
-
|
|
10468
|
-
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
const
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
}
|
|
10711
|
+
const mode = input2?.mode === "focus" ? "focus" : "condense";
|
|
10712
|
+
const supabase = getSupabaseClient();
|
|
10713
|
+
const { data: source, error: sourceError } = await supabase.from("thread_replies").select("*").eq("id", replyId).single();
|
|
10714
|
+
if (sourceError) return c.json({ error: sourceError.message }, 404);
|
|
10715
|
+
const { data: sourceThread, error: sourceThreadError } = await supabase.from("threads").select("*").eq("id", source.thread_id).single();
|
|
10716
|
+
if (sourceThreadError) return c.json({ error: sourceThreadError.message }, 404);
|
|
10717
|
+
if (!(await globalChannelMeta(c)).has(sourceThread.channel_id)) {
|
|
10718
|
+
return c.json({ error: "not found" }, 404);
|
|
10719
|
+
}
|
|
10720
|
+
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 });
|
|
10721
|
+
const priorRows = priorReplies ?? [];
|
|
10722
|
+
const sourceText = slackMrkdwnToMarkdown(source.body);
|
|
10723
|
+
const FOCUS_CONTEXT_BELOW_CHARS = 280;
|
|
10724
|
+
const inputRows = mode === "focus" ? priorRows.slice(sourceText.length < FOCUS_CONTEXT_BELOW_CHARS ? -2 : -1) : priorRows;
|
|
10725
|
+
const transcript = inputRows.map((r) => `${r.author}: ${slackMrkdwnToMarkdown(r.body)}`).join("\n\n");
|
|
10726
|
+
const { title, body } = await buildForkTopic(transcript || sourceText, sourceText, mode);
|
|
10727
|
+
const backlink = `/chat/${encodeURIComponent(sourceThread.channel_id)}/${sourceThread.id}#m-${replyId.slice(0, 8)}`;
|
|
10728
|
+
const openingBody = `${body}
|
|
10481
10729
|
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
"
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
}
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10730
|
+
> \u2442 Forked from [a message by ${source.author}](${backlink})`;
|
|
10731
|
+
const { data: thread, error: threadError } = await supabase.from("threads").insert({ channel_id: sourceThread.channel_id, title, author }).select().single();
|
|
10732
|
+
if (threadError) return c.json({ error: threadError.message }, 500);
|
|
10733
|
+
const { data: reply, error: replyError } = await supabase.from("thread_replies").insert({ thread_id: thread.id, author, body: openingBody }).select().single();
|
|
10734
|
+
if (replyError) return c.json({ error: replyError.message }, 500);
|
|
10735
|
+
onMessage?.({
|
|
10736
|
+
threadId: thread.id,
|
|
10737
|
+
replyId: reply.id,
|
|
10738
|
+
author,
|
|
10739
|
+
body: openingBody
|
|
10740
|
+
});
|
|
10741
|
+
return c.json({ ...thread, replies: [reply] }, 201);
|
|
10742
|
+
});
|
|
10743
|
+
app.post("/threads/:id/replies", async (c) => {
|
|
10744
|
+
const threadId = c.req.param("id");
|
|
10745
|
+
const input2 = await c.req.json();
|
|
10746
|
+
if (!isNonEmptyString(input2.author) || !isNonEmptyString(input2.body)) {
|
|
10747
|
+
return c.json({ errors: ["author and body are required"] }, 400);
|
|
10748
|
+
}
|
|
10749
|
+
const clientKey2 = isNonEmptyString(input2.clientKey) ? input2.clientKey : null;
|
|
10750
|
+
const effort = requestedEffort(c, input2.effort);
|
|
10751
|
+
if (!await threadIsKnown(threadId, c)) return c.json({ error: "not found" }, 404);
|
|
10752
|
+
const model = requestedModel(c, input2.model) ?? await threadModel(threadId);
|
|
10753
|
+
const { data, error: error2 } = await getSupabaseClient().from("thread_replies").insert({
|
|
10754
|
+
thread_id: threadId,
|
|
10755
|
+
author: input2.author,
|
|
10756
|
+
body: input2.body,
|
|
10757
|
+
client_key: clientKey2,
|
|
10758
|
+
model,
|
|
10759
|
+
effort
|
|
10760
|
+
}).select().single();
|
|
10761
|
+
if (error2) {
|
|
10762
|
+
if (clientKey2 && error2.code === "23505") {
|
|
10763
|
+
const { data: existing } = await getSupabaseClient().from("thread_replies").select("*").eq("client_key", clientKey2).single();
|
|
10764
|
+
if (existing) return c.json(existing, 200);
|
|
10765
|
+
}
|
|
10766
|
+
return c.json({ error: error2.message }, 500);
|
|
10767
|
+
}
|
|
10768
|
+
onMessage?.({
|
|
10769
|
+
threadId,
|
|
10770
|
+
replyId: data.id,
|
|
10771
|
+
author: input2.author,
|
|
10772
|
+
body: input2.body,
|
|
10773
|
+
model: data.model ?? void 0,
|
|
10774
|
+
effort: effortFromRow(data.effort)
|
|
10775
|
+
});
|
|
10776
|
+
return c.json(data, 201);
|
|
10777
|
+
});
|
|
10778
|
+
return app;
|
|
10506
10779
|
}
|
|
10507
10780
|
|
|
10508
|
-
// ../api/src/dashboard/
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10781
|
+
// ../api/src/dashboard/doc-shares-routes.ts
|
|
10782
|
+
import { Hono as Hono14 } from "hono";
|
|
10783
|
+
|
|
10784
|
+
// ../api/src/dashboard/doc-share-features.ts
|
|
10785
|
+
import { DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
|
|
10786
|
+
var DEFAULT_FEATURES = {
|
|
10787
|
+
audioEnabled: false,
|
|
10788
|
+
askEnabled: true,
|
|
10789
|
+
socialEnabled: false,
|
|
10790
|
+
brandingEnabled: true,
|
|
10791
|
+
translationEnabled: false,
|
|
10792
|
+
translationLangs: []
|
|
10793
|
+
};
|
|
10794
|
+
var HTML_FEATURES = {
|
|
10795
|
+
audioEnabled: false,
|
|
10796
|
+
askEnabled: false,
|
|
10797
|
+
socialEnabled: false,
|
|
10798
|
+
brandingEnabled: false,
|
|
10799
|
+
translationEnabled: false,
|
|
10800
|
+
translationLangs: []
|
|
10801
|
+
};
|
|
10802
|
+
function resolveFeatures(requested, existing) {
|
|
10803
|
+
const base = existing ?? DEFAULT_FEATURES;
|
|
10804
|
+
return {
|
|
10805
|
+
audioEnabled: requested?.audio ?? base.audioEnabled,
|
|
10806
|
+
askEnabled: requested?.ask ?? base.askEnabled,
|
|
10807
|
+
socialEnabled: requested?.social ?? base.socialEnabled,
|
|
10808
|
+
brandingEnabled: requested?.branding ?? base.brandingEnabled,
|
|
10809
|
+
translationEnabled: requested?.translation ?? base.translationEnabled,
|
|
10810
|
+
// Replaced wholesale, not merged: the allowlist IS the answer to "which
|
|
10811
|
+
// languages does this link offer", and a merge would make removing one
|
|
10812
|
+
// impossible.
|
|
10813
|
+
translationLangs: requested?.translationLangs ?? base.translationLangs
|
|
10814
|
+
};
|
|
10517
10815
|
}
|
|
10518
|
-
function
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10816
|
+
async function deleteShareAssets(keys) {
|
|
10817
|
+
const client2 = getR2Client();
|
|
10818
|
+
const bucket = getR2Bucket();
|
|
10819
|
+
for (const key of keys) {
|
|
10820
|
+
try {
|
|
10821
|
+
await client2.send(new DeleteObjectCommand2({ Bucket: bucket, Key: key }));
|
|
10822
|
+
} catch {
|
|
10823
|
+
}
|
|
10524
10824
|
}
|
|
10525
10825
|
}
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10826
|
+
async function updateShareFeatures(share, requested) {
|
|
10827
|
+
if (share.kind === "html") return share;
|
|
10828
|
+
if (share.kind === "excalidraw") {
|
|
10829
|
+
if (requested.branding === void 0) return share;
|
|
10830
|
+
return updateDocShareFeatures(share.id, { ...share, brandingEnabled: requested.branding });
|
|
10831
|
+
}
|
|
10832
|
+
const features = resolveFeatures(requested, share);
|
|
10833
|
+
const updated = await updateDocShareFeatures(share.id, features);
|
|
10834
|
+
if (!updated) return null;
|
|
10835
|
+
if (features.audioEnabled) void queueShareAudio(updated);
|
|
10836
|
+
else if (share.audioEnabled) await dropShareAudio(share);
|
|
10837
|
+
return updated;
|
|
10530
10838
|
}
|
|
10531
10839
|
|
|
10532
10840
|
// ../api/src/dashboard/doc-share-publish.ts
|
|
@@ -10537,7 +10845,7 @@ import { CopyObjectCommand, PutObjectCommand as PutObjectCommand2 } from "@aws-s
|
|
|
10537
10845
|
var SHARE_ASSET_PLACEHOLDER = "/share/{{flyspace-share-id}}/assets/";
|
|
10538
10846
|
|
|
10539
10847
|
// ../api/src/dashboard/share-language.ts
|
|
10540
|
-
var
|
|
10848
|
+
var GROQ_API_URL6 = "https://api.groq.com/openai/v1/chat/completions";
|
|
10541
10849
|
var CLASSIFIER_MODEL = "openai/gpt-oss-20b";
|
|
10542
10850
|
var SAMPLE_CHARS = 4e3;
|
|
10543
10851
|
var NOISE = [
|
|
@@ -10576,7 +10884,7 @@ async function detectSourceLanguage(title, content) {
|
|
|
10576
10884
|
const sample = languageSample(title, content);
|
|
10577
10885
|
if (sample.length < MIN_SAMPLE_CHARS) return null;
|
|
10578
10886
|
try {
|
|
10579
|
-
const res = await fetch(
|
|
10887
|
+
const res = await fetch(GROQ_API_URL6, {
|
|
10580
10888
|
method: "POST",
|
|
10581
10889
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
10582
10890
|
body: JSON.stringify({
|
|
@@ -10806,7 +11114,7 @@ function resolveChannel2(c) {
|
|
|
10806
11114
|
}
|
|
10807
11115
|
var LIST_LIMIT = 500;
|
|
10808
11116
|
function createDocShareListRouter() {
|
|
10809
|
-
const app = new
|
|
11117
|
+
const app = new Hono14();
|
|
10810
11118
|
app.get("/shares", async (c) => {
|
|
10811
11119
|
const viewer = c.get("auth");
|
|
10812
11120
|
const readable = await filterReadableDocsChannels(
|
|
@@ -10850,7 +11158,7 @@ function createDocShareListRouter() {
|
|
|
10850
11158
|
return app;
|
|
10851
11159
|
}
|
|
10852
11160
|
function createDocShareWriteRouter(agent = {}) {
|
|
10853
|
-
const app = new
|
|
11161
|
+
const app = new Hono14();
|
|
10854
11162
|
app.use("/:id/docs/share", requireDocsAccess);
|
|
10855
11163
|
app.use("/:id/docs/share/*", requireDocsAccess);
|
|
10856
11164
|
app.use("/:id/docs/shares", requireDocsAccess);
|
|
@@ -11023,7 +11331,7 @@ function createDocShareWriteRouter(agent = {}) {
|
|
|
11023
11331
|
}
|
|
11024
11332
|
|
|
11025
11333
|
// ../api/src/dashboard/search-routes.ts
|
|
11026
|
-
import { Hono as
|
|
11334
|
+
import { Hono as Hono15 } from "hono";
|
|
11027
11335
|
function matchScore(q, fields) {
|
|
11028
11336
|
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
11029
11337
|
if (terms.length === 0) return 0;
|
|
@@ -11171,7 +11479,7 @@ function searchAutomations(q) {
|
|
|
11171
11479
|
}
|
|
11172
11480
|
var RESULT_LIMIT = 30;
|
|
11173
11481
|
function createSearchRouter() {
|
|
11174
|
-
const app = new
|
|
11482
|
+
const app = new Hono15();
|
|
11175
11483
|
app.get("/", async (c) => {
|
|
11176
11484
|
const q = c.req.query("q")?.trim();
|
|
11177
11485
|
if (!q) return c.json({ results: [] });
|
|
@@ -11187,14 +11495,14 @@ function createSearchRouter() {
|
|
|
11187
11495
|
}
|
|
11188
11496
|
|
|
11189
11497
|
// ../api/src/dashboard/feature-flags-routes.ts
|
|
11190
|
-
import { Hono as
|
|
11498
|
+
import { Hono as Hono16 } from "hono";
|
|
11191
11499
|
async function isFeatureEnabled(c, flagId) {
|
|
11192
11500
|
const auth = c.get("auth");
|
|
11193
11501
|
const enabled = await listEnabledFeatureFlags(auth.workspaceId);
|
|
11194
11502
|
return resolveFeatureFlags(enabled, isDevHost(c))[flagId];
|
|
11195
11503
|
}
|
|
11196
11504
|
function createFeatureFlagsRouter() {
|
|
11197
|
-
const app = new
|
|
11505
|
+
const app = new Hono16();
|
|
11198
11506
|
app.get("/", async (c) => {
|
|
11199
11507
|
const auth = c.get("auth");
|
|
11200
11508
|
const devHost = isDevHost(c);
|
|
@@ -11221,7 +11529,7 @@ function createFeatureFlagsRouter() {
|
|
|
11221
11529
|
|
|
11222
11530
|
// ../api/src/dashboard/uploads-routes.ts
|
|
11223
11531
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
11224
|
-
import { Hono as
|
|
11532
|
+
import { Hono as Hono17 } from "hono";
|
|
11225
11533
|
import { PutObjectCommand as PutObjectCommand3, GetObjectCommand as GetObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11226
11534
|
import { getSignedUrl as getSignedUrl3 } from "@aws-sdk/s3-request-presigner";
|
|
11227
11535
|
var MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -11236,7 +11544,7 @@ function encodeKeyForUrl(key) {
|
|
|
11236
11544
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
11237
11545
|
}
|
|
11238
11546
|
function createUploadsRouter() {
|
|
11239
|
-
const app = new
|
|
11547
|
+
const app = new Hono17();
|
|
11240
11548
|
app.post("/uploads", async (c) => {
|
|
11241
11549
|
const body = await c.req.parseBody().catch(() => null);
|
|
11242
11550
|
const file = body?.["file"];
|
|
@@ -11281,14 +11589,14 @@ function createUploadsRouter() {
|
|
|
11281
11589
|
}
|
|
11282
11590
|
|
|
11283
11591
|
// ../api/src/dashboard/transcribe-routes.ts
|
|
11284
|
-
import { Hono as
|
|
11592
|
+
import { Hono as Hono18 } from "hono";
|
|
11285
11593
|
var GROQ_TRANSCRIBE_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
|
|
11286
11594
|
var MODEL = "whisper-large-v3-turbo";
|
|
11287
11595
|
var MAX_AUDIO_BYTES = 5 * 1024 * 1024;
|
|
11288
11596
|
var MAX_PROMPT_CHARS = 400;
|
|
11289
11597
|
var NO_SPEECH_THRESHOLD = 0.6;
|
|
11290
11598
|
function createTranscribeRouter() {
|
|
11291
|
-
const app = new
|
|
11599
|
+
const app = new Hono18();
|
|
11292
11600
|
app.post("/transcribe", async (c) => {
|
|
11293
11601
|
if (!await isFeatureEnabled(c, "dictation"))
|
|
11294
11602
|
return c.json({ error: "feature not enabled" }, 403);
|
|
@@ -11328,7 +11636,7 @@ function createTranscribeRouter() {
|
|
|
11328
11636
|
|
|
11329
11637
|
// ../api/src/dashboard/avatars-routes.ts
|
|
11330
11638
|
import { createHash as createHash9, randomUUID as randomUUID4 } from "crypto";
|
|
11331
|
-
import { Hono as
|
|
11639
|
+
import { Hono as Hono19 } from "hono";
|
|
11332
11640
|
import sharp4 from "sharp";
|
|
11333
11641
|
import { PutObjectCommand as PutObjectCommand4, DeleteObjectCommand as DeleteObjectCommand3 } from "@aws-sdk/client-s3";
|
|
11334
11642
|
var AVATAR_SIZE = 192;
|
|
@@ -11381,7 +11689,7 @@ function avatarKeyPrefix() {
|
|
|
11381
11689
|
return `${KEY_PREFIX2}/${getProfile()}/`;
|
|
11382
11690
|
}
|
|
11383
11691
|
function createAvatarsRouter() {
|
|
11384
|
-
const app = new
|
|
11692
|
+
const app = new Hono19();
|
|
11385
11693
|
app.get("/avatars/slack", async (c) => {
|
|
11386
11694
|
const ctx = await resolveAuthContext(c);
|
|
11387
11695
|
if (!ctx) return c.json({ error: "not signed in" }, 401);
|
|
@@ -11430,7 +11738,7 @@ function createAvatarsRouter() {
|
|
|
11430
11738
|
}
|
|
11431
11739
|
|
|
11432
11740
|
// ../api/src/dashboard/members-routes.ts
|
|
11433
|
-
import { Hono as
|
|
11741
|
+
import { Hono as Hono20 } from "hono";
|
|
11434
11742
|
function inviteLinkFor(c, token) {
|
|
11435
11743
|
const url = new URL("/auth/invite", callbackUrl(c));
|
|
11436
11744
|
url.searchParams.set("token", token);
|
|
@@ -11438,7 +11746,7 @@ function inviteLinkFor(c, token) {
|
|
|
11438
11746
|
}
|
|
11439
11747
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11440
11748
|
function createMembersRouter() {
|
|
11441
|
-
const members = new
|
|
11749
|
+
const members = new Hono20();
|
|
11442
11750
|
members.get("/", async (c) => {
|
|
11443
11751
|
const ctx = await resolveAuthContext(c);
|
|
11444
11752
|
if (ctx?.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
@@ -11665,7 +11973,7 @@ function createMembersRouter() {
|
|
|
11665
11973
|
}
|
|
11666
11974
|
|
|
11667
11975
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11668
|
-
import { Hono as
|
|
11976
|
+
import { Hono as Hono21 } from "hono";
|
|
11669
11977
|
|
|
11670
11978
|
// ../api/src/store/notification-store.ts
|
|
11671
11979
|
function rowToNotification(row) {
|
|
@@ -11733,7 +12041,7 @@ async function markAllRead(channelIds, userId) {
|
|
|
11733
12041
|
|
|
11734
12042
|
// ../api/src/dashboard/notifications-routes.ts
|
|
11735
12043
|
function createNotificationsRouter() {
|
|
11736
|
-
const notifications = new
|
|
12044
|
+
const notifications = new Hono21();
|
|
11737
12045
|
notifications.get("/", async (c) => {
|
|
11738
12046
|
const auth = c.get("auth");
|
|
11739
12047
|
const channelIds = getProfileChannelRegistry().filter((ch) => canSeeChannel(ch, auth)).map((ch) => ch.id);
|
|
@@ -11760,7 +12068,7 @@ function createNotificationsRouter() {
|
|
|
11760
12068
|
return notifications;
|
|
11761
12069
|
}
|
|
11762
12070
|
function createThreadReadRouter() {
|
|
11763
|
-
const threads = new
|
|
12071
|
+
const threads = new Hono21();
|
|
11764
12072
|
threads.post("/threads/:threadId/read", async (c) => {
|
|
11765
12073
|
const { userId } = c.get("auth");
|
|
11766
12074
|
const threadId = c.req.param("threadId");
|
|
@@ -11772,7 +12080,7 @@ function createThreadReadRouter() {
|
|
|
11772
12080
|
}
|
|
11773
12081
|
|
|
11774
12082
|
// ../api/src/dashboard/push-routes.ts
|
|
11775
|
-
import { Hono as
|
|
12083
|
+
import { Hono as Hono22 } from "hono";
|
|
11776
12084
|
|
|
11777
12085
|
// ../api/src/push/send.ts
|
|
11778
12086
|
import webpush from "web-push";
|
|
@@ -11875,7 +12183,7 @@ async function pushToUser(userId, payload) {
|
|
|
11875
12183
|
|
|
11876
12184
|
// ../api/src/dashboard/push-routes.ts
|
|
11877
12185
|
function createPushRouter() {
|
|
11878
|
-
const push = new
|
|
12186
|
+
const push = new Hono22();
|
|
11879
12187
|
push.get("/key", (c) => c.json({ publicKey: getVapidPublicKey() }));
|
|
11880
12188
|
push.post("/subscribe", async (c) => {
|
|
11881
12189
|
const { userId } = c.get("auth");
|
|
@@ -11906,7 +12214,7 @@ function createPushRouter() {
|
|
|
11906
12214
|
}
|
|
11907
12215
|
|
|
11908
12216
|
// ../api/src/dashboard/mcp-oauth-routes.ts
|
|
11909
|
-
import { Hono as
|
|
12217
|
+
import { Hono as Hono23 } from "hono";
|
|
11910
12218
|
|
|
11911
12219
|
// ../api/src/mcp/crypto.ts
|
|
11912
12220
|
import { createCipheriv, createDecipheriv, randomBytes as randomBytes7, timingSafeEqual } from "crypto";
|
|
@@ -12421,7 +12729,7 @@ function backToDashboard(c, to, params) {
|
|
|
12421
12729
|
return c.redirect(`${target.pathname}${target.search}`);
|
|
12422
12730
|
}
|
|
12423
12731
|
function createMcpOAuthRouter() {
|
|
12424
|
-
const app = new
|
|
12732
|
+
const app = new Hono23();
|
|
12425
12733
|
app.get("/oauth/:provider/start", async (c) => {
|
|
12426
12734
|
const auth = c.get("auth");
|
|
12427
12735
|
if (auth.role !== "admin") return c.json({ error: "admin access required" }, 403);
|
|
@@ -12548,7 +12856,7 @@ function createMcpOAuthRouter() {
|
|
|
12548
12856
|
}
|
|
12549
12857
|
|
|
12550
12858
|
// ../api/src/dashboard/mcp-routes.ts
|
|
12551
|
-
import { Hono as
|
|
12859
|
+
import { Hono as Hono24 } from "hono";
|
|
12552
12860
|
|
|
12553
12861
|
// ../api/src/mcp/transport.ts
|
|
12554
12862
|
var KINDS = ["inherit", "npx", "local", "http", "sse"];
|
|
@@ -12678,7 +12986,7 @@ function bindingsByConnection() {
|
|
|
12678
12986
|
return map;
|
|
12679
12987
|
}
|
|
12680
12988
|
function createMcpRouter() {
|
|
12681
|
-
const app = new
|
|
12989
|
+
const app = new Hono24();
|
|
12682
12990
|
app.get("/providers", async (c) => {
|
|
12683
12991
|
const denied = requireAdmin(c.get("auth"));
|
|
12684
12992
|
if (denied) return c.json({ error: denied }, 403);
|
|
@@ -13060,7 +13368,7 @@ async function buildThreadUsage(threadTs) {
|
|
|
13060
13368
|
}
|
|
13061
13369
|
|
|
13062
13370
|
// ../api/src/dashboard/channel-members-routes.ts
|
|
13063
|
-
import { Hono as
|
|
13371
|
+
import { Hono as Hono25 } from "hono";
|
|
13064
13372
|
async function isChannelAdmin(ctx, channelId) {
|
|
13065
13373
|
if (ctx.role === "admin") return true;
|
|
13066
13374
|
const membership = await getMembershipByUserId(ctx.workspaceId, ctx.userId);
|
|
@@ -13069,7 +13377,7 @@ async function isChannelAdmin(ctx, channelId) {
|
|
|
13069
13377
|
return members.some((m) => m.membershipId === membership.id && m.role === "channel_admin");
|
|
13070
13378
|
}
|
|
13071
13379
|
function createChannelMembersRouter() {
|
|
13072
|
-
const router = new
|
|
13380
|
+
const router = new Hono25();
|
|
13073
13381
|
router.get("/:id/members", async (c) => {
|
|
13074
13382
|
const ctx = await resolveAuthContext(c);
|
|
13075
13383
|
if (!ctx) return c.json({ error: "workspace access required" }, 403);
|
|
@@ -13139,7 +13447,7 @@ function createChannelMembersRouter() {
|
|
|
13139
13447
|
}
|
|
13140
13448
|
|
|
13141
13449
|
// ../api/src/dashboard/memories-routes.ts
|
|
13142
|
-
import { Hono as
|
|
13450
|
+
import { Hono as Hono26 } from "hono";
|
|
13143
13451
|
async function readPatch(c) {
|
|
13144
13452
|
const body = await c.req.json().catch(() => ({}));
|
|
13145
13453
|
const patch = {};
|
|
@@ -13167,7 +13475,7 @@ var requireAgentMemory = async (c, next) => {
|
|
|
13167
13475
|
await next();
|
|
13168
13476
|
};
|
|
13169
13477
|
function createMemoriesRouter() {
|
|
13170
|
-
const router = new
|
|
13478
|
+
const router = new Hono26();
|
|
13171
13479
|
for (const path of [
|
|
13172
13480
|
"/:id/memories",
|
|
13173
13481
|
"/:id/memories/*",
|
|
@@ -13240,7 +13548,7 @@ function createMemoriesRouter() {
|
|
|
13240
13548
|
return router;
|
|
13241
13549
|
}
|
|
13242
13550
|
function createScopedMemoriesRouter() {
|
|
13243
|
-
const router = new
|
|
13551
|
+
const router = new Hono26();
|
|
13244
13552
|
router.use("*", requireAgentMemory);
|
|
13245
13553
|
const workspace = (c) => ({
|
|
13246
13554
|
scope: "workspace",
|
|
@@ -13299,218 +13607,6 @@ function createScopedMemoriesRouter() {
|
|
|
13299
13607
|
return router;
|
|
13300
13608
|
}
|
|
13301
13609
|
|
|
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
13610
|
// ../api/src/dashboard/host-status-signal.ts
|
|
13515
13611
|
var HOST_STATUS_CHANNEL = "host-status";
|
|
13516
13612
|
var channelReady = null;
|
|
@@ -13610,6 +13706,7 @@ async function resolveThreadLinks(sessions2) {
|
|
|
13610
13706
|
}
|
|
13611
13707
|
|
|
13612
13708
|
// ../api/src/dashboard/data-api.ts
|
|
13709
|
+
var SELF_UPGRADE_MIN_VERSION = "0.1.30";
|
|
13613
13710
|
function registerDataRoutes(api, agent = {}) {
|
|
13614
13711
|
api.get("/profile", (c) => c.json({ profile: getProfile() }));
|
|
13615
13712
|
api.get("/host/status", async (c) => {
|
|
@@ -13623,7 +13720,20 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13623
13720
|
const cliVersion = agent.getHostCliVersion?.(getProfile()) ?? null;
|
|
13624
13721
|
const latest2 = cliVersion ? await latestCliVersion() : null;
|
|
13625
13722
|
const outdated = !!(cliVersion && latest2 && isOlderVersion(cliVersion, latest2));
|
|
13626
|
-
|
|
13723
|
+
const selfUpgrade = !!cliVersion && !isOlderVersion(cliVersion, SELF_UPGRADE_MIN_VERSION);
|
|
13724
|
+
return c.json({
|
|
13725
|
+
online: !paused,
|
|
13726
|
+
cliVersion,
|
|
13727
|
+
selfUpgrade,
|
|
13728
|
+
...outdated ? { latestCliVersion: latest2 } : {}
|
|
13729
|
+
});
|
|
13730
|
+
});
|
|
13731
|
+
api.post("/host/upgrade", async (c) => {
|
|
13732
|
+
const auth = c.get("auth");
|
|
13733
|
+
if (!await isHostOwner(auth)) return c.json({ error: "only the Host owner can upgrade it" }, 403);
|
|
13734
|
+
const res = await agent.upgradeHost?.();
|
|
13735
|
+
if (!res) return c.json({ error: "Host is offline" }, 503);
|
|
13736
|
+
return c.json(res, 202);
|
|
13627
13737
|
});
|
|
13628
13738
|
api.get("/channels", async (c) => {
|
|
13629
13739
|
const auth = c.get("auth");
|
|
@@ -13811,9 +13921,9 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13811
13921
|
void broadcastHostStatusChange();
|
|
13812
13922
|
let cancelled = 0;
|
|
13813
13923
|
if (body.paused) {
|
|
13814
|
-
const
|
|
13815
|
-
if (
|
|
13816
|
-
const { data } = await getSupabaseClient().from("threads").select("id, channel_id").in("id",
|
|
13924
|
+
const running2 = await agent.listAgentRuns?.() ?? [];
|
|
13925
|
+
if (running2.length > 0) {
|
|
13926
|
+
const { data } = await getSupabaseClient().from("threads").select("id, channel_id").in("id", running2);
|
|
13817
13927
|
const profile = getProfile();
|
|
13818
13928
|
for (const t of data ?? []) {
|
|
13819
13929
|
if (findOwningProfile(t.channel_id) !== profile) continue;
|
|
@@ -13831,7 +13941,7 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13831
13941
|
const host = trustedHost(c);
|
|
13832
13942
|
const known = await listKnownWorkspaces();
|
|
13833
13943
|
const hostProfile = profileFromHost(host, known);
|
|
13834
|
-
const
|
|
13944
|
+
const running2 = new Set(await agent.listRunningProfiles?.() ?? []);
|
|
13835
13945
|
const auth = c.get("auth");
|
|
13836
13946
|
const [{ data: memberships }, workspaces] = await Promise.all([
|
|
13837
13947
|
getSupabaseClient().from("memberships").select("workspace_id").eq("user_id", auth.userId),
|
|
@@ -13848,7 +13958,7 @@ function registerDataRoutes(api, agent = {}) {
|
|
|
13848
13958
|
return {
|
|
13849
13959
|
name,
|
|
13850
13960
|
active: name === active,
|
|
13851
|
-
running:
|
|
13961
|
+
running: running2.has(name),
|
|
13852
13962
|
displayName: branding.displayName,
|
|
13853
13963
|
logo: branding.logo,
|
|
13854
13964
|
hostname: (hostProfile ? workspaceHostname(host, name) : null) ?? void 0
|
|
@@ -14222,16 +14332,16 @@ function installSkillSync(profiles, options = {}) {
|
|
|
14222
14332
|
if (!databaseReachable()) return () => {
|
|
14223
14333
|
};
|
|
14224
14334
|
const { intervalMs = SYNC_INTERVAL_MS, onError = (e) => console.error("[skill-sync]", e) } = options;
|
|
14225
|
-
let
|
|
14335
|
+
let running2 = false;
|
|
14226
14336
|
const tick = async () => {
|
|
14227
|
-
if (
|
|
14228
|
-
|
|
14337
|
+
if (running2) return;
|
|
14338
|
+
running2 = true;
|
|
14229
14339
|
try {
|
|
14230
14340
|
for (const profile of profiles) {
|
|
14231
14341
|
await runWithProfile(profile, () => pushSkillScopes()).catch(onError);
|
|
14232
14342
|
}
|
|
14233
14343
|
} finally {
|
|
14234
|
-
|
|
14344
|
+
running2 = false;
|
|
14235
14345
|
}
|
|
14236
14346
|
};
|
|
14237
14347
|
void tick();
|
|
@@ -14671,7 +14781,7 @@ import {
|
|
|
14671
14781
|
readSync as readSync2,
|
|
14672
14782
|
readdirSync as readdirSync5,
|
|
14673
14783
|
readFileSync as readFileSync20,
|
|
14674
|
-
realpathSync as
|
|
14784
|
+
realpathSync as realpathSync4,
|
|
14675
14785
|
renameSync,
|
|
14676
14786
|
rmSync as rmSync8,
|
|
14677
14787
|
statSync as statSync6,
|
|
@@ -14783,8 +14893,8 @@ var inFlight5 = /* @__PURE__ */ new Map();
|
|
|
14783
14893
|
async function readOrCreateDocTranslation(input2) {
|
|
14784
14894
|
const contentHash = translationHash(input2.title, input2.content);
|
|
14785
14895
|
const key = `${input2.channelId}:${input2.sourcePath}:${contentHash}:${input2.lang}`;
|
|
14786
|
-
const
|
|
14787
|
-
if (
|
|
14896
|
+
const running2 = inFlight5.get(key);
|
|
14897
|
+
if (running2) return running2;
|
|
14788
14898
|
const job = (async () => {
|
|
14789
14899
|
const cached = await getDocTranslation(input2.channelId, input2.sourcePath, input2.lang, contentHash);
|
|
14790
14900
|
if (cached) return cached;
|
|
@@ -17514,7 +17624,7 @@ function moveDocEntry(root, from, to) {
|
|
|
17514
17624
|
return { status: "into-self" };
|
|
17515
17625
|
}
|
|
17516
17626
|
try {
|
|
17517
|
-
if (
|
|
17627
|
+
if (realpathSync4(finalAbs) !== realpathSync4(absFrom)) return { status: "exists" };
|
|
17518
17628
|
} catch {
|
|
17519
17629
|
}
|
|
17520
17630
|
renameSync(absFrom, finalAbs);
|
|
@@ -18741,6 +18851,26 @@ function readDocShareBundle({ cwd, sourcePath }) {
|
|
|
18741
18851
|
return { status: "ok", kind, title, content: content + renderAttachments(attachments), assets };
|
|
18742
18852
|
}
|
|
18743
18853
|
|
|
18854
|
+
// src/dashboard/host-upgrade.ts
|
|
18855
|
+
import { spawn as spawn7 } from "child_process";
|
|
18856
|
+
import { mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
|
|
18857
|
+
import { join as join27 } from "path";
|
|
18858
|
+
var running = false;
|
|
18859
|
+
function startHostUpgrade() {
|
|
18860
|
+
if (running) return { started: false };
|
|
18861
|
+
mkdirSync13(paths.LOG_DIR, { recursive: true });
|
|
18862
|
+
const log5 = openSync3(join27(paths.LOG_DIR, "host-upgrade.log"), "a");
|
|
18863
|
+
const child = spawn7(process.execPath, [process.argv[1] ?? "", "upgrade"], {
|
|
18864
|
+
detached: true,
|
|
18865
|
+
stdio: ["ignore", log5, log5]
|
|
18866
|
+
});
|
|
18867
|
+
running = true;
|
|
18868
|
+
child.on("exit", () => running = false);
|
|
18869
|
+
child.on("error", () => running = false);
|
|
18870
|
+
child.unref();
|
|
18871
|
+
return { started: true };
|
|
18872
|
+
}
|
|
18873
|
+
|
|
18744
18874
|
// src/dashboard/agent-bridge-host.ts
|
|
18745
18875
|
var HOSTED_DATA_SOCKET_ORIGIN = "wss://flyspace-api.fly.dev";
|
|
18746
18876
|
function dataSocketUrl() {
|
|
@@ -18781,7 +18911,8 @@ function localAgentCallbacks(chatAgent) {
|
|
|
18781
18911
|
docsRequest: (req) => handleDocsRequest(req),
|
|
18782
18912
|
hostRequest: (req) => handleHostRequest(req),
|
|
18783
18913
|
listSessions: () => listLocalSessions(),
|
|
18784
|
-
|
|
18914
|
+
upgradeHost: () => startHostUpgrade(),
|
|
18915
|
+
scanOnboarding: (slug) => scanOnboarding(slug),
|
|
18785
18916
|
linkProjectFolders: (slug, items) => linkProjectFolders(slug, items),
|
|
18786
18917
|
isHostOnline: () => true
|
|
18787
18918
|
};
|
|
@@ -19038,7 +19169,7 @@ function startPushWatcher() {
|
|
|
19038
19169
|
|
|
19039
19170
|
// src/dashboard/machine-server.ts
|
|
19040
19171
|
import { existsSync as existsSync31 } from "fs";
|
|
19041
|
-
import { join as
|
|
19172
|
+
import { join as join28 } from "path";
|
|
19042
19173
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
19043
19174
|
|
|
19044
19175
|
// src/dashboard/app-icon-routes.ts
|
|
@@ -19120,7 +19251,7 @@ function pngResponse(c, png) {
|
|
|
19120
19251
|
}
|
|
19121
19252
|
|
|
19122
19253
|
// src/dashboard/machine-server.ts
|
|
19123
|
-
var WEB_DIST =
|
|
19254
|
+
var WEB_DIST = join28(paths.WEB_ROOT, "dist");
|
|
19124
19255
|
async function bootMachine() {
|
|
19125
19256
|
await reconcileAll().catch(
|
|
19126
19257
|
(err) => console.error("[dashboard] reconcileAll failed:", err.message)
|
|
@@ -19140,14 +19271,14 @@ async function bootMachine() {
|
|
|
19140
19271
|
function mountSpa(app) {
|
|
19141
19272
|
app.route("/share", createPublicShareAssetRouter(WEB_DIST));
|
|
19142
19273
|
app.route("/", createAppIconRouter());
|
|
19143
|
-
if (existsSync31(WEB_DIST) || existsSync31(
|
|
19274
|
+
if (existsSync31(WEB_DIST) || existsSync31(join28(paths.WEB_ROOT, "package.json"))) {
|
|
19144
19275
|
app.use("/*", serveStatic({ root: WEB_DIST }));
|
|
19145
19276
|
}
|
|
19146
19277
|
app.get("/assets/*", (c) => c.text("stale asset \u2014 reload for the current build", 404));
|
|
19147
|
-
const spaFallback = serveStatic({ path:
|
|
19278
|
+
const spaFallback = serveStatic({ path: join28(WEB_DIST, "index.html") });
|
|
19148
19279
|
app.get("*", async (c, next) => {
|
|
19149
|
-
if (!existsSync31(
|
|
19150
|
-
const isCheckout = existsSync31(
|
|
19280
|
+
if (!existsSync31(join28(WEB_DIST, "index.html"))) {
|
|
19281
|
+
const isCheckout = existsSync31(join28(paths.WEB_ROOT, "package.json"));
|
|
19151
19282
|
return c.text(
|
|
19152
19283
|
isCheckout ? `Workspace UI build missing (${WEB_DIST}).
|
|
19153
19284
|
Run \`pnpm build:web\` from the repo root.
|
|
@@ -19276,13 +19407,13 @@ function flagValue(args, name) {
|
|
|
19276
19407
|
function openBrowser2(url) {
|
|
19277
19408
|
const cmd = platform7() === "darwin" ? "open" : platform7() === "win32" ? "start" : "xdg-open";
|
|
19278
19409
|
try {
|
|
19279
|
-
|
|
19410
|
+
spawn8(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
19280
19411
|
} catch {
|
|
19281
19412
|
}
|
|
19282
19413
|
}
|
|
19283
19414
|
function ensureWebBuild() {
|
|
19284
|
-
if (existsSync32(
|
|
19285
|
-
if (!existsSync32(
|
|
19415
|
+
if (existsSync32(join29(paths.WEB_ROOT, "dist", "index.html"))) return;
|
|
19416
|
+
if (!existsSync32(join29(paths.WEB_ROOT, "package.json"))) return;
|
|
19286
19417
|
console.log(" packages/ui/dist missing, building the dashboard UI...");
|
|
19287
19418
|
const result = spawnSync("pnpm", ["build"], {
|
|
19288
19419
|
cwd: paths.WEB_ROOT,
|
|
@@ -19332,7 +19463,7 @@ async function runDashboard(args) {
|
|
|
19332
19463
|
}
|
|
19333
19464
|
|
|
19334
19465
|
// src/commands/workspace.ts
|
|
19335
|
-
import { existsSync as existsSync33, mkdirSync as
|
|
19466
|
+
import { existsSync as existsSync33, mkdirSync as mkdirSync14, writeFileSync as writeFileSync13 } from "fs";
|
|
19336
19467
|
var USAGE7 = `
|
|
19337
19468
|
Usage: flyspace workspace <subcommand> [options]
|
|
19338
19469
|
|
|
@@ -19436,7 +19567,7 @@ async function provisionChannels(specs) {
|
|
|
19436
19567
|
}
|
|
19437
19568
|
const cwd = expandPath(spec.cwd);
|
|
19438
19569
|
if (!existsSync33(cwd)) {
|
|
19439
|
-
|
|
19570
|
+
mkdirSync14(cwd, { recursive: true });
|
|
19440
19571
|
console.log(` created cwd ${cwd}`);
|
|
19441
19572
|
}
|
|
19442
19573
|
await createChannel({ name: spec.name, cwd, position: index });
|
|
@@ -19513,10 +19644,10 @@ async function runReconcileCmd(args) {
|
|
|
19513
19644
|
}
|
|
19514
19645
|
|
|
19515
19646
|
// src/cli.ts
|
|
19516
|
-
import { join as
|
|
19647
|
+
import { join as join30 } from "path";
|
|
19517
19648
|
var home = flyspaceHome();
|
|
19518
19649
|
if (home) {
|
|
19519
|
-
const bin =
|
|
19650
|
+
const bin = join30(home, "bin");
|
|
19520
19651
|
const path = process.env.PATH ?? "";
|
|
19521
19652
|
if (!path.split(":").includes(bin)) process.env.PATH = path ? `${bin}:${path}` : bin;
|
|
19522
19653
|
}
|
|
@@ -19654,7 +19785,7 @@ async function main() {
|
|
|
19654
19785
|
break;
|
|
19655
19786
|
case "-v":
|
|
19656
19787
|
case "--version":
|
|
19657
|
-
printRaw("0.1.
|
|
19788
|
+
printRaw("0.1.30");
|
|
19658
19789
|
break;
|
|
19659
19790
|
case "-h":
|
|
19660
19791
|
case "--help":
|