Mobile sync support (#4173)

* WIP on mobile connections
todo: register devices
todo: data sync or connection

* improve connection flow and registration
add streaming from service
TODO: user scoping

* dev build mobile support

* fix path

* handle relative URLs

* handle localhost access in product

* add device de-register

* sync styles

* move UI to be out of the normal path since beta only

* Add user scoping to mobile connection requests
Remigrate DB for user associations
Implement temp token registration to prevent unauthorized device registration requests
cleanup middlewares
This commit is contained in:
Timothy Carambat 2025-07-31 12:28:03 -07:00 committed by GitHub
parent 755ef4bb80
commit c218a0dfe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1215 additions and 12 deletions

View File

@ -6,7 +6,7 @@ concurrency:
on:
push:
branches: ['multilingual-native-embedder-selection'] # put your current branch to create a build. Core team only.
branches: ['mobile-support'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'

View File

@ -28,6 +28,7 @@
"moment": "^2.30.1",
"onnxruntime-web": "^1.18.0",
"pluralize": "^8.0.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-beautiful-dnd": "13.1.1",
"react-confetti-explosion": "^2.1.2",
@ -74,4 +75,4 @@
"tailwindcss": "^3.3.1",
"vite": "^4.3.0"
}
}
}

View File

@ -89,6 +89,9 @@ const CommunityHubImportItem = lazy(
const SystemPromptVariables = lazy(
() => import("@/pages/Admin/SystemPromptVariables")
);
const MobileConnections = lazy(
() => import("@/pages/GeneralSettings/MobileConnections")
);
export default function App() {
return (
@ -264,6 +267,11 @@ export default function App() {
path="/settings/community-hub/import-item"
element={<AdminRoute Component={CommunityHubImportItem} />}
/>
<Route
path="/settings/mobile-connections"
element={<ManagerRoute Component={MobileConnections} />}
/>
</Routes>
<ToastContainer />
<KeyboardShortcutsHelp />

View File

@ -0,0 +1,70 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
/**
* @typedef {Object} MobileConnection
* @property {string} id - The database ID of the device.
* @property {string} deviceId - The device ID of the device.
* @property {string} deviceOs - The operating system of the device.
* @property {boolean} approved - Whether the device is approved.
* @property {string} createdAt - The date and time the device was created.
*/
const MobileConnection = {
/**
* Get the connection info for the mobile app.
* @returns {Promise<{connectionUrl: string|null}>} The connection info.
*/
getConnectionInfo: async function () {
return await fetch(`${API_BASE}/mobile/connect-info`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => false);
},
/**
* Get all the devices from the database.
* @returns {Promise<MobileDevice[]>} The devices.
*/
getDevices: async function () {
return await fetch(`${API_BASE}/mobile/devices`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.then((res) => res.devices || [])
.catch(() => []);
},
/**
* Delete a device from the database.
* @param {string} deviceId - The database ID of the device to delete.
* @returns {Promise<{message: string}>} The deleted device.
*/
deleteDevice: async function (id) {
return await fetch(`${API_BASE}/mobile/${id}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => false);
},
/**
* Update a device in the database.
* @param {string} id - The database ID of the device to update.
* @param {Object} updates - The updates to apply to the device.
* @returns {Promise<{updates: MobileDevice}>} The updated device.
*/
updateDevice: async function (id, updates = {}) {
return await fetch(`${API_BASE}/mobile/update/${id}`, {
method: "POST",
body: JSON.stringify(updates),
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => false);
},
};
export default MobileConnection;

View File

@ -1,4 +1,5 @@
import LiveSyncToggle from "./Features/LiveSync/toggle";
import paths from "@/utils/paths";
export const configurableFeatures = {
experimental_live_file_sync: {
@ -6,4 +7,10 @@ export const configurableFeatures = {
component: LiveSyncToggle,
key: "experimental_live_file_sync",
},
experimental_mobile_connections: {
title: "AnythingLLM Mobile",
href: paths.settings.mobileConnections(),
key: "experimental_mobile_connections",
autoEnabled: true,
},
};

View File

@ -131,18 +131,32 @@ function FeatureList({
? "bg-white/10 light:bg-theme-bg-sidebar "
: ""
}`}
onClick={() => handleClick?.(feature)}
onClick={() => {
if (settings?.href) window.location.replace(settings.href);
else handleClick?.(feature);
}}
>
<div className="text-sm font-light">{settings.title}</div>
<div className="flex items-center gap-x-2">
<div className="text-sm text-theme-text-secondary font-medium">
{activeFeatures.includes(settings.key) ? "On" : "Off"}
</div>
<CaretRight
size={14}
weight="bold"
className="text-theme-text-secondary"
/>
{settings.autoEnabled ? (
<>
<div className="text-sm text-theme-text-secondary font-medium">
On
</div>
<div className="w-[14px]" />
</>
) : (
<>
<div className="text-sm text-theme-text-secondary font-medium">
{activeFeatures.includes(settings.key) ? "On" : "Off"}
</div>
<CaretRight
size={14}
weight="bold"
className="text-theme-text-secondary"
/>
</>
)}
</div>
</div>
))}

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

@ -0,0 +1,149 @@
import { X } from "@phosphor-icons/react";
import ModalWrapper from "@/components/ModalWrapper";
import BG from "./bg.png";
import { QRCodeSVG } from "qrcode.react";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import MobileConnection from "@/models/mobile";
import PreLoader from "@/components/Preloader";
import Logo from "@/media/logo/anything-llm-infinity.png";
export default function MobileConnectModal({ isOpen, onClose }) {
return (
<ModalWrapper isOpen={isOpen}>
<div
className="relative w-full rounded-lg shadow"
style={{
minHeight: "60vh",
maxWidth: "70vw",
backgroundImage: `url(${BG})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<button
onClick={onClose}
type="button"
className="absolute top-4 right-4 transition-all duration-300 bg-transparent rounded-lg text-sm p-1 inline-flex items-center hover:bg-theme-modal-border hover:border-theme-modal-border hover:border-opacity-50 border-transparent border"
>
<X size={24} weight="bold" className="text-[#FFF]" />
</button>
<div className="flex w-full h-full justify-between p-[35px]">
{/* left column */}
<div className="flex flex-col w-1/2 gap-y-[16px]">
<p className="text-[#FFF] text-xl font-bold">
Go mobile. Stay local. AnythingLLM Mobile.
</p>
<p className="text-[#FFF] text-lg">
AnythingLLM for mobile allows you to connect or clone your
workspace's chats, threads and documents for you to use on the go.
<br />
<br />
Run with local models on your phone privately or relay chats
directly to this instance seamlessly.
</p>
</div>
{/* right column */}
<div className="flex flex-col items-center justify-center shrink-0 w-1/2 gap-y-[16px]">
<div className="bg-white/10 rounded-lg p-[40px] w-[300px] h-[300px] flex flex-col gap-y-[16px] items-center justify-center">
<ConnectionQrCode isOpen={isOpen} />
</div>
<p className="text-[#FFF] text-sm w-[300px] text-center">
Scan the QR code with the AnythingLLM Mobile app to enable live
sync of your workspaces, chats, threads and documents.
<br />
<Link
to="https://docs.anythingllm.com/mobile"
className="text-cta-button font-semibold"
>
Learn more
</Link>
</p>
</div>
</div>
</div>
</ModalWrapper>
);
}
/**
* Process the connection url to make it absolute if it is a relative path
* @param {string} url
* @returns {string}
*/
function processConnectionUrl(url) {
/*
* In dev mode, the connectionURL() method uses the `ip` module
* see server/models/mobileDevice.js `connectionURL()` method.
*
* In prod mode, this method returns the absolute path since we will always want to use
* the real instance hostname. If the domain changes, we should be able to inherit it from the client side
* since the backend has no knowledge of the domain since typically it is run behind a reverse proxy or in a container - or both.
* So `ip` is useless in prod mode since it would only resolve to the internal IP address of the container or if non-containerized,
* the local IP address may not be the preferred instance access point (eg: using custom domain)
*
* If the url does not start with http, we assume it is a relative path and add the origin to it.
* Then we check if the hostname is localhost, 127.0.0.1, or 0.0.0.0. If it is, we throw an error since that is not
* a LAN resolvable address that other devices can use to connect to the instance.
*/
if (url.startsWith("http")) return new URL(url);
const connectionUrl = new URL(`${window.location.origin}${url}`);
if (["localhost", "127.0.0.1", "0.0.0.0"].includes(connectionUrl.hostname))
throw new Error(
"Please open this page via your machines private IP address or custom domain. Localhost URLs will not work with the mobile app."
);
return connectionUrl.toString();
}
const ConnectionQrCode = ({ isOpen }) => {
const [connectionInfo, setConnectionInfo] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!isOpen) return;
setIsLoading(true);
MobileConnection.getConnectionInfo()
.then((res) => {
if (res.error) throw new Error(res.error);
const url = processConnectionUrl(res.connectionUrl);
setConnectionInfo(url);
})
.catch((err) => {
setError(err.message);
})
.finally(() => {
setIsLoading(false);
});
}, [isOpen]);
if (isLoading) return <PreLoader size="[100px]" />;
if (error)
return (
<p className="text-red-500 text-sm w-[300px] p-4 text-center">{error}</p>
);
const size = {
width: 35 * 1.5,
height: 22 * 1.5,
};
return (
<QRCodeSVG
value={connectionInfo}
size={300}
bgColor="transparent"
fgColor="white"
level="L"
imageSettings={{
src: Logo,
x: 300 / 2 - size.width / 2,
y: 300 / 2 - size.height / 2,
height: size.height,
width: size.width,
excavate: true,
}}
/>
);
};

View File

@ -0,0 +1,90 @@
import showToast from "@/utils/toast";
import MobileConnection from "@/models/mobile";
import { useState } from "react";
import moment from "moment";
import { BugDroid, AppleLogo } from "@phosphor-icons/react";
import { Link } from "react-router-dom";
import paths from "@/utils/paths";
export default function DeviceRow({ device, removeDevice }) {
const [status, setStatus] = useState(device.approved);
const handleApprove = async () => {
await MobileConnection.updateDevice(device.id, { approved: true });
showToast("Device access granted", "info");
setStatus(true);
};
const handleDeny = async () => {
await MobileConnection.deleteDevice(device.id);
showToast("Device access denied", "info");
setStatus(false);
removeDevice(device.id);
};
return (
<>
<tr className="bg-transparent text-white text-opacity-80 text-xs font-medium border-b border-white/10 h-10">
<td scope="row" className="px-6 whitespace-nowrap">
<div className="flex items-center gap-x-2">
{device.deviceOs === "ios" ? (
<AppleLogo
weight="fill"
size={16}
className="fill-theme-text-primary"
/>
) : (
<BugDroid
weight="fill"
size={16}
className="fill-theme-text-primary"
/>
)}
<span className="text-sm">{device.deviceName}</span>
</div>
</td>
<td className="px-6">
<div className="flex items-center gap-x-2">
{moment(device.createdAt).format("lll")}
{device.user && (
<div className="flex items-center gap-x-1">
<span className="text-xs text-theme-text-secondary">by</span>
<Link
to={paths.settings.users()}
className="text-xs text-theme-text-secondary hover:underline hover:text-cta-button"
>
{device.user.username}
</Link>
</div>
)}
</div>
</td>
<td className="px-6 flex items-center gap-x-6 h-full mt-1">
{status ? (
<button
onClick={handleDeny}
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:light:bg-red-50 hover:bg-opacity-10`}
>
Revoke
</button>
) : (
<>
<button
onClick={handleApprove}
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:bg-opacity-10 hover:light:bg-green-50 hover:light:text-green-500 hover:text-green-300`}
>
Approve Access
</button>
<button
onClick={handleDeny}
className={`border-none flex items-center justify-center text-xs font-medium text-white/80 light:text-black/80 rounded-lg p-1 hover:bg-white hover:bg-opacity-10 hover:light:bg-red-50 hover:light:text-red-500 hover:text-red-300`}
>
Deny
</button>
</>
)}
</td>
</tr>
</>
);
}

View File

@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import Sidebar from "@/components/SettingsSidebar";
import * as Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { QrCode } from "@phosphor-icons/react";
import { useModal } from "@/hooks/useModal";
import CTAButton from "@/components/lib/CTAButton";
import MobileConnection from "@/models/mobile";
import ConnectionModal from "./ConnectionModal";
import DeviceRow from "./DeviceRow";
import { isMobile } from "react-device-detect";
export default function MobileDevices() {
const { isOpen, openModal, closeModal } = useModal();
const [loading, setLoading] = useState(true);
const [devices, setDevices] = useState([]);
const fetchDevices = async () => {
const foundDevices = await MobileConnection.getDevices();
setDevices(foundDevices);
if (foundDevices.length !== 0 && !isOpen) closeModal();
return foundDevices;
};
useEffect(() => {
fetchDevices()
.then((devices) => {
if (devices.length === 0) openModal();
return devices;
})
.finally(() => {
setLoading(false);
});
const interval = setInterval(fetchDevices, 5_000);
return () => clearInterval(interval);
}, []);
const removeDevice = (id) => {
setDevices((prevDevices) =>
prevDevices.filter((device) => device.id !== id)
);
};
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex md:mt-0 mt-6">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-1 pb-6 border-white/10 border-b-2">
<div className="items-center flex gap-x-4">
<p className="text-lg leading-6 font-bold text-theme-text-primary">
Connected Mobile Devices
</p>
</div>
<p className="text-xs leading-[18px] font-base text-theme-text-secondary mt-2">
These are the devices that are connected to your desktop
application to sync chats, workspaces, and more.
</p>
</div>
<div className="w-full justify-end flex">
<CTAButton
onClick={openModal}
className="mt-3 mr-0 mb-4 md:-mb-14 z-10"
>
<QrCode className="h-4 w-4" weight="bold" /> Register New Device
</CTAButton>
</div>
<div className="overflow-x-auto mt-6">
{loading ? (
<Skeleton.default
height="80vh"
width="100%"
highlightColor="var(--theme-bg-primary)"
baseColor="var(--theme-bg-secondary)"
count={1}
className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm"
containerClassName="flex w-full"
/>
) : (
<table className="w-full text-xs text-left rounded-lg min-w-[640px] border-spacing-0">
<thead className="text-theme-text-secondary text-xs leading-[18px] font-bold uppercase border-white/10 border-b">
<tr>
<th scope="col" className="px-6 py-3">
Device Name
</th>
<th scope="col" className="px-6 py-3">
Registered
</th>
<th scope="col" className="px-6 py-3">
{" "}
</th>
</tr>
</thead>
<tbody>
{devices.length === 0 ? (
<tr className="bg-transparent text-theme-text-secondary text-sm font-medium">
<td colSpan="4" className="px-6 py-4 text-center">
No devices found
</td>
</tr>
) : (
devices.map((device) => (
<DeviceRow
key={device.id}
device={device}
removeDevice={removeDevice}
/>
))
)}
</tbody>
</table>
)}
</div>
</div>
</div>
<ConnectionModal isOpen={isOpen} onClose={closeModal} />
</div>
);
}

View File

@ -164,6 +164,9 @@ export default {
experimental: () => {
return `/settings/beta-features`;
},
mobileConnections: () => {
return `/settings/mobile-connections`;
},
},
agents: {
builder: () => {

View File

@ -3136,6 +3136,11 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qrcode.react@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-4.2.0.tgz#1bce8363f348197d145c0da640929a24c83cbca3"
integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"

View File

@ -0,0 +1,160 @@
const { validatedRequest } = require("../../utils/middleware/validatedRequest");
const { MobileDevice } = require("../../models/mobileDevice");
const { handleMobileCommand } = require("./utils");
const { validDeviceToken, validRegistrationToken } = require("./middleware");
const { reqBody } = require("../../utils/http");
const {
flexUserRoleValid,
ROLES,
} = require("../../utils/middleware/multiUserProtected");
function mobileEndpoints(app) {
if (!app) return;
/**
* Gets all the devices from the database.
* @param {import("express").Request} request
* @param {import("express").Response} response
*/
app.get(
"/mobile/devices",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_request, response) => {
try {
const devices = await MobileDevice.where({}, null, null, {
user: { select: { id: true, username: true } },
});
return response.status(200).json({ devices });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
/**
* Updates the device status via an updates object.
* @param {import("express").Request} request
* @param {import("express").Response} response
*/
app.post(
"/mobile/update/:id",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const body = reqBody(request);
const updates = await MobileDevice.update(
Number(request.params.id),
body
);
if (updates.error)
return response.status(400).json({ error: updates.error });
return response.status(200).json({ updates });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
/**
* Deletes a device from the database.
* @param {import("express").Request} request
* @param {import("express").Response} response
*/
app.delete(
"/mobile/:id",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (request, response) => {
try {
const device = await MobileDevice.get({
id: Number(request.params.id),
});
if (!device)
return response.status(404).json({ error: "Device not found" });
await MobileDevice.delete(device.id);
return response.status(200).json({ message: "Device deleted" });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
app.get(
"/mobile/connect-info",
[validatedRequest, flexUserRoleValid([ROLES.admin])],
async (_request, response) => {
try {
return response.status(200).json({
connectionUrl: MobileDevice.connectionURL(response.locals?.user),
});
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
/**
* Checks if the device auth token is valid
* against approved devices.
*/
app.get("/mobile/auth", [validDeviceToken], async (_, response) => {
try {
return response
.status(200)
.json({ success: true, message: "Device authenticated" });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
});
/**
* Registers a new device (is open so that the mobile app can register itself)
* Will create a new device in the database but requires approval by the user
* before it can be used.
* @param {import("express").Request} request
* @param {import("express").Response} response
*/
app.post(
"/mobile/register",
[validRegistrationToken],
async (request, response) => {
try {
const body = reqBody(request);
const result = await MobileDevice.create({
deviceOs: body.deviceOs,
deviceName: body.deviceName,
userId: response.locals?.user?.id,
});
if (result.error)
return response.status(400).json({ error: result.error });
return response.status(200).json({
token: result.device.token,
platform: MobileDevice.platform,
});
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
app.post(
"/mobile/send/:command",
[validDeviceToken],
async (request, response) => {
try {
return handleMobileCommand(request, response);
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
}
);
}
module.exports = { mobileEndpoints };

View File

@ -0,0 +1,97 @@
const { MobileDevice } = require("../../../models/mobileDevice");
const { SystemSettings } = require("../../../models/systemSettings");
const { User } = require("../../../models/user");
/**
* Validates the device id from the request headers by checking if the device
* exists in the database and is approved.
* @param {import("express").Request} request
* @param {import("express").Response} response
* @param {import("express").NextFunction} next
*/
async function validDeviceToken(request, response, next) {
try {
const token = request.header("x-anythingllm-mobile-device-token");
if (!token)
return response.status(400).json({ error: "Device token is required" });
const device = await MobileDevice.get(
{ token: String(token) },
{ user: true }
);
if (!device)
return response.status(400).json({ error: "Device not found" });
if (!device.approved)
return response.status(400).json({ error: "Device not approved" });
// If the device is associated with a user then we can associate it with the locals
// so we can reuse it later.
if (device.user) {
if (device.user.suspended)
return response.status(400).json({ error: "User is suspended." });
response.locals.user = device.user;
}
delete device.user;
response.locals.device = device;
next();
} catch (error) {
console.error("validDeviceToken", error);
response.status(500).json({ error: "Invalid middleware response" });
}
}
/**
* Validates a temporary registration token that is passed in the request
* and associates the user with the token (if valid). Temporary token is consumed
* and cannot be used again after this middleware is called.
* @param {*} request
* @param {*} response
* @param {*} next
*/
async function validRegistrationToken(request, response, next) {
try {
const authHeader = request.header("Authorization");
const tempToken = authHeader ? authHeader.split(" ")[1] : null;
if (!tempToken)
return response
.status(400)
.json({ error: "Registration token is required" });
const tempTokenData = MobileDevice.tempToken(tempToken);
if (!tempTokenData)
return response
.status(400)
.json({ error: "Invalid or expired registration token" });
// If in multi-user mode, we need to validate the user id
// associated exists, is not banned and then associate with locals so we can reuse it later.
// If not in multi-user mode then simply having a valid token is enough.
const multiUserMode = await SystemSettings.isMultiUserMode();
if (multiUserMode) {
if (!tempTokenData.userId)
return response
.status(400)
.json({ error: "User id not found in registration token" });
const user = await User.get({ id: Number(tempTokenData.userId) });
if (!user) return response.status(400).json({ error: "User not found" });
if (user.suspended)
return response
.status(400)
.json({ error: "User is suspended - cannot register device" });
response.locals.user = user;
}
next();
} catch (error) {
console.error("validRegistrationToken:error", error);
response.status(500).json({
error: "Invalid middleware response from validRegistrationToken",
});
}
}
module.exports = {
validDeviceToken,
validRegistrationToken,
};

View File

@ -0,0 +1,195 @@
const { Workspace } = require("../../../models/workspace");
const { WorkspaceChats } = require("../../../models/workspaceChats");
const { WorkspaceThread } = require("../../../models/workspaceThread");
const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler");
const { reqBody } = require("../../../utils/http");
const prisma = require("../../../utils/prisma");
const { getModelTag } = require("../../utils");
const { MobileDevice } = require("../../../models/mobileDevice");
/**
*
* @param {import("express").Request} request
* @param {import("express").Response} response
* @returns
*/
async function handleMobileCommand(request, response) {
const { command } = request.params;
const user = response.locals.user ?? null;
const body = reqBody(request);
if (command === "workspaces") {
const workspaces = user
? await Workspace.whereWithUser(user, {})
: await Workspace.where({});
for (const workspace of workspaces) {
const [threadCount, chatCount] = await Promise.all([
prisma.workspace_threads.count({
where: {
workspace_id: workspace.id,
...(user ? { user_id: user.id } : {}),
},
}),
prisma.workspace_chats.count({
where: {
workspaceId: workspace.id,
include: true,
...(user ? { user_id: user.id } : {}),
},
}),
]);
workspace.threadCount = threadCount;
workspace.chatCount = chatCount;
workspace.platform = MobileDevice.platform;
}
return response.status(200).json({ workspaces });
}
if (command === "workspace-content") {
const workspace = user
? await Workspace.getWithUser(user, { slug: String(body.workspaceSlug) })
: await Workspace.get({ slug: String(body.workspaceSlug) });
if (!workspace)
return response.status(400).json({ error: "Workspace not found" });
const threads = [
{
id: 0,
name: "Default Thread",
slug: "default-thread",
workspace_id: workspace.id,
createdAt: new Date(),
lastUpdatedAt: new Date(),
},
...(await prisma.workspace_threads.findMany({
where: {
workspace_id: workspace.id,
...(user ? { user_id: user.id } : {}),
},
})),
];
const chats = (
await prisma.workspace_chats.findMany({
where: {
workspaceId: workspace.id,
include: true,
...(user ? { user_id: user.id } : {}),
},
})
).map((chat) => ({
...chat,
// Create a dummy thread_id for the default thread so the chats can be mapped correctly.
...(chat.thread_id === null ? { thread_id: 0 } : {}),
createdAt: chat.createdAt.toISOString(),
lastUpdatedAt: chat.lastUpdatedAt.toISOString(),
}));
return response.status(200).json({ threads, chats });
}
// Get the model for this workspace (workspace -> system)
if (command === "model-tag") {
const { workspaceSlug } = body;
const workspace = user
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
: await Workspace.get({ slug: String(workspaceSlug) });
if (!workspace)
return response.status(400).json({ error: "Workspace not found" });
if (workspace.chatModel)
return response.status(200).json({ model: workspace.chatModel });
else return response.status(200).json({ model: getModelTag() });
}
if (command === "reset-chat") {
const { workspaceSlug, threadSlug } = body;
const workspace = user
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
: await Workspace.get({ slug: String(workspaceSlug) });
if (!workspace)
return response.status(400).json({ error: "Workspace not found" });
const threadId = threadSlug
? await prisma.workspace_threads.findFirst({
where: {
workspace_id: workspace.id,
slug: String(threadSlug),
...(user ? { user_id: user.id } : {}),
},
})?.id
: null;
await WorkspaceChats.markThreadHistoryInvalidV2({
workspaceId: workspace.id,
...(user ? { user_id: user.id } : {}),
thread_id: threadId, // if threadId is null, this will reset the default thread.
});
return response.status(200).json({ success: true });
}
if (command === "new-thread") {
const { workspaceSlug } = body;
const workspace = user
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
: await Workspace.get({ slug: String(workspaceSlug) });
if (!workspace)
return response.status(400).json({ error: "Workspace not found" });
const { thread } = await WorkspaceThread.new(workspace, user?.id);
return response.status(200).json({ thread });
}
if (command === "stream-chat") {
const { workspaceSlug = null, threadSlug = null, message } = body;
if (!workspaceSlug)
return response.status(400).json({ error: "Workspace ID is required" });
else if (!message)
return response.status(400).json({ error: "Message is required" });
const workspace = user
? await Workspace.getWithUser(user, { slug: String(workspaceSlug) })
: await Workspace.get({ slug: String(workspaceSlug) });
if (!workspace)
return response.status(400).json({ error: "Workspace not found" });
const thread = threadSlug
? await prisma.workspace_threads.findFirst({
where: {
workspace_id: workspace.id,
slug: String(threadSlug),
...(user ? { user_id: user.id } : {}),
},
})
: null;
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Content-Type", "text/event-stream");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Connection", "keep-alive");
response.flushHeaders();
await ApiChatHandler.streamChat({
response,
workspace,
thread,
message,
mode: "chat",
user: user,
sessionId: null,
attachments: [],
reset: false,
});
return response.end();
}
if (command === "unregister-device") {
if (!response.locals.device)
return response.status(200).json({ success: true });
await MobileDevice.delete(response.locals.device.id);
return response.status(200).json({ success: true });
}
return response.status(400).json({ error: "Invalid command" });
}
module.exports = {
handleMobileCommand,
};

View File

@ -28,6 +28,7 @@ const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
const { mcpServersEndpoints } = require("./endpoints/mcpServers");
const { mobileEndpoints } = require("./endpoints/mobile");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
@ -65,6 +66,7 @@ developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);
agentFlowEndpoints(apiRouter);
mcpServersEndpoints(apiRouter);
mobileEndpoints(apiRouter);
// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);

View File

@ -0,0 +1,230 @@
const prisma = require("../utils/prisma");
const { v4: uuidv4 } = require("uuid");
const ip = require("ip");
/**
* @typedef {Object} TemporaryMobileDeviceRequest
* @property {number|null} userId - User id to associate creation of key with.
* @property {number} createdAt - Timestamp of when the token was created.
* @property {number} expiresAt - Timestamp of when the token expires.
*/
/**
* Temporary map to store mobile device requests
* that are not yet approved. Generates a simple JWT
* that expires and is tied to the user (if provided)
* This token must be provided during /register event.
* @type {Map<string, TemporaryMobileDeviceRequest>}
*/
const TemporaryMobileDeviceRequests = new Map();
const MobileDevice = {
platform: "server",
validDeviceOs: ["android"],
tablename: "desktop_mobile_devices",
writable: ["approved"],
validators: {
approved: (value) => {
if (typeof value !== "boolean") return "Must be a boolean";
return null;
},
},
/**
* Looks up and consumes a temporary token that was registered
* Will return null if the token is not found or expired.
* @param {string} token - The temporary token to lookup
* @returns {TemporaryMobileDeviceRequest|null} Temp token details
*/
tempToken: (token = null) => {
try {
if (!token || !TemporaryMobileDeviceRequests.has(token)) return null;
const tokenData = TemporaryMobileDeviceRequests.get(token);
if (tokenData.expiresAt < Date.now()) return null;
return tokenData;
} catch (error) {
return null;
} finally {
TemporaryMobileDeviceRequests.delete(token);
}
},
/**
* Registers a temporary token for a mobile device request
* This is just using a random token to identify the request
* @security Note: If we use a JWT the QR code that encodes it becomes extremely complex
* and noisy as QR codes have byte limits that could be exceeded with JWTs. Since this is
* a temporary token that is only used to register a device and is short lived we can use UUIDs.
* @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
* @returns {string} The temporary token
*/
registerTempToken: function (user = null) {
let tokenData = {};
if (user) tokenData.userId = user.id;
else tokenData.userId = null;
// Set short lived expiry to this mapping
const createdAt = Date.now();
tokenData.createdAt = createdAt;
tokenData.expiresAt = createdAt + 3 * 60_000;
const tempToken = uuidv4().split("-").slice(0, 3).join("");
TemporaryMobileDeviceRequests.set(tempToken, tokenData);
// Run this on register since there is no BG task to do this.
this.cleanupExpiredTokens();
return tempToken;
},
/**
* Cleans up expired temporary registration tokens
* Should run quick since this mapping is wiped often
* and does not live past restarts.
*/
cleanupExpiredTokens: function () {
const now = Date.now();
for (const [token, data] of TemporaryMobileDeviceRequests.entries()) {
if (data.expiresAt < now) TemporaryMobileDeviceRequests.delete(token);
}
},
/**
* Returns the connection URL for the mobile app to use to connect to the backend.
* Since you have to have a valid session to call /mobile/connect-info we can pre-register
* a temporary token for the user that is passed back to /mobile/register and can lookup
* who a device belongs to so we can scope it's access token.
* @param {import("@prisma/client").users|null} user - User to get connection URL for in Multi-User Mode
* @returns {string}
*/
connectionURL: function (user = null) {
let baseUrl = "/api/mobile";
if (process.env.NODE_ENV === "production") baseUrl = "/api/mobile";
else
baseUrl = `http://${ip.address()}:${process.env.SERVER_PORT || 3001}/api/mobile`;
const tempToken = this.registerTempToken(user);
baseUrl = `${baseUrl}?t=${tempToken}`;
return baseUrl;
},
/**
* Creates a new device for the mobile app
* @param {object} params - The params to create the device with.
* @param {string} params.deviceOs - Device os to associate creation of key with.
* @param {string} params.deviceName - Device name to associate creation of key with.
* @param {number|null} params.userId - User id to associate creation of key with.
* @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
*/
create: async function ({ deviceOs, deviceName, userId = null }) {
try {
if (!deviceOs || !deviceName)
return { device: null, error: "Device OS and name are required" };
if (!this.validDeviceOs.includes(deviceOs))
return { device: null, error: `Invalid device OS - ${deviceOs}` };
const device = await prisma.desktop_mobile_devices.create({
data: {
deviceName: String(deviceName),
deviceOs: String(deviceOs).toLowerCase(),
token: uuidv4(),
userId: userId ? Number(userId) : null,
},
});
return { device, error: null };
} catch (error) {
console.error("Failed to create mobile device", error);
return { device: null, error: error.message };
}
},
/**
* Validated existing API key
* @param {string} id - Device id (db id)
* @param {object} updates - Updates to apply to device
* @returns {Promise<{device: import("@prisma/client").desktop_mobile_devices|null, error:string|null}>}
*/
update: async function (id, updates = {}) {
const device = await this.get({ id: parseInt(id) });
if (!device) return { device: null, error: "Device not found" };
const validUpdates = {};
for (const [key, value] of Object.entries(updates)) {
if (!this.writable.includes(key)) continue;
const validation = this.validators[key](value);
if (validation !== null) return { device: null, error: validation };
validUpdates[key] = value;
}
// If no updates, return the device.
if (Object.keys(validUpdates).length === 0) return { device, error: null };
const updatedDevice = await prisma.desktop_mobile_devices.update({
where: { id: device.id },
data: validUpdates,
});
return { device: updatedDevice, error: null };
},
/**
* Fetches mobile device by params.
* @param {object} clause - Prisma props for search
* @returns {Promise<import("@prisma/client").desktop_mobile_devices[]>}
*/
get: async function (clause = {}, include = null) {
try {
const device = await prisma.desktop_mobile_devices.findFirst({
where: clause,
...(include !== null ? { include } : {}),
});
return device;
} catch (error) {
console.error("FAILED TO GET MOBILE DEVICE.", error);
return [];
}
},
/**
* Deletes mobile device by db id.
* @param {number} id - database id of mobile device
* @returns {Promise<{success: boolean, error:string|null}>}
*/
delete: async function (id) {
try {
await prisma.desktop_mobile_devices.delete({
where: { id: parseInt(id) },
});
return { success: true, error: null };
} catch (error) {
console.error("Failed to delete mobile device", error);
return { success: false, error: error.message };
}
},
/**
* Gets mobile devices by params
* @param {object} clause
* @param {number|null} limit
* @param {object|null} orderBy
* @returns {Promise<import("@prisma/client").desktop_mobile_devices[]>}
*/
where: async function (
clause = {},
limit = null,
orderBy = null,
include = null
) {
try {
const devices = await prisma.desktop_mobile_devices.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null ? { orderBy } : {}),
...(include !== null ? { include } : {}),
});
return devices;
} catch (error) {
console.error("FAILED TO GET MOBILE DEVICES.", error.message);
return [];
}
},
};
module.exports = { MobileDevice };

View File

@ -216,6 +216,11 @@ const User = {
}
},
/**
* Returns a user object based on the clause provided.
* @param {Object} clause - The clause to use to find the user.
* @returns {Promise<import("@prisma/client").users|null>} The user object or null if not found.
*/
get: async function (clause = {}) {
try {
const user = await prisma.users.findFirst({ where: clause });

View File

@ -54,6 +54,7 @@
"extract-json-from-string": "^1.0.1",
"fast-levenshtein": "^3.0.0",
"graphql": "^16.7.1",
"ip": "^2.0.1",
"joi": "^17.11.0",
"joi-password-complexity": "^5.2.0",
"js-tiktoken": "^1.0.8",
@ -100,4 +101,4 @@
"nodemon": "^2.0.22",
"prettier": "^3.0.3"
}
}
}

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "desktop_mobile_devices" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"deviceOs" TEXT NOT NULL,
"deviceName" TEXT NOT NULL,
"token" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "desktop_mobile_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "desktop_mobile_devices_token_key" ON "desktop_mobile_devices"("token");
-- CreateIndex
CREATE INDEX "desktop_mobile_devices_userId_idx" ON "desktop_mobile_devices"("userId");

View File

@ -82,6 +82,7 @@ model users {
temporary_auth_tokens temporary_auth_tokens[]
system_prompt_variables system_prompt_variables[]
prompt_history prompt_history[]
desktop_mobile_devices desktop_mobile_devices[]
}
model recovery_codes {
@ -356,3 +357,17 @@ model prompt_history {
@@index([workspaceId])
}
// Schema specific to mobile app <> Desktop app connection
model desktop_mobile_devices {
id Int @id @default(autoincrement())
deviceOs String
deviceName String
token String @unique
approved Boolean @default(false)
userId Int?
createdAt DateTime @default(now())
user users? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}

View File

@ -16,6 +16,12 @@ function queryParams(request) {
return request.query;
}
/**
* Creates a JWT with the given info and expiry
* @param {object} info - The info to include in the JWT
* @param {string} expiry - The expiry time for the JWT (default: 30 days)
* @returns {string} The JWT
*/
function makeJWT(info = {}, expiry = "30d") {
if (!process.env.JWT_SECRET)
throw new Error("Cannot create JWT as JWT_SECRET is unset.");

View File

@ -5122,6 +5122,11 @@ internal-slot@^1.0.7:
hasown "^2.0.0"
side-channel "^1.0.4"
ip@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105"
integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"