mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(device): device registry TRPC (register / list / update / remove) (#15299)
Server-side foundation for the device registry. Builds on the `devices` table (already on canary) so devices persist beyond the gateway's in-memory WS sessions and stay visible/bindable while offline. - new DeviceModel: register upserts on (userId, deviceId) and only refreshes machine-reported fields + lastSeenAt, so user-owned friendlyName / defaultCwd / recentCwds survive re-registration - device.* router gains register / updateDevice / removeDevice (DB row only, no OIDC token revocation); listDevices is rewritten as a DB ∪ online union so offline devices stay listed and not-yet-registered online devices surface as transient entries - HeteroDeviceSwitcher adapts to the richer listDevices shape (null-safe platform, prefers friendlyName) Desktop / CLI auto-registration ships in a follow-up PR that depends on this. Part of LOBE-9572. Closes LOBE-9575. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment node
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { devices, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { DeviceModel } from '../device';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'device-model-test-user-id';
|
||||
const deviceModel = new DeviceModel(serverDB, userId);
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: 'device-model-other-user' }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users).where(eq(users.id, userId));
|
||||
await serverDB.delete(devices).where(eq(devices.userId, userId));
|
||||
});
|
||||
|
||||
describe('DeviceModel', () => {
|
||||
describe('register', () => {
|
||||
it('should insert a new device', async () => {
|
||||
const result = await deviceModel.register({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'My-Mac.local',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result).toMatchObject({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'My-Mac.local',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should upsert on (userId, deviceId) and refresh machine fields', async () => {
|
||||
await deviceModel.register({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'old-host',
|
||||
identitySource: 'fallback',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
await deviceModel.register({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'new-host',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
const rows = await serverDB.query.devices.findMany({
|
||||
where: and(eq(devices.userId, userId), eq(devices.deviceId, 'dev-1')),
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
hostname: 'new-host',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT overwrite user-owned fields on re-register', async () => {
|
||||
await deviceModel.register({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'host',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
});
|
||||
await deviceModel.update('dev-1', {
|
||||
defaultCwd: '/Users/me/work',
|
||||
friendlyName: 'My Work Mac',
|
||||
recentCwds: ['/Users/me/work', '/Users/me/tmp'],
|
||||
});
|
||||
|
||||
// Re-register (e.g. user logs in again / reconnects)
|
||||
await deviceModel.register({
|
||||
deviceId: 'dev-1',
|
||||
hostname: 'host',
|
||||
identitySource: 'machine-id',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
const row = await deviceModel.findByDeviceId('dev-1');
|
||||
expect(row).toMatchObject({
|
||||
defaultCwd: '/Users/me/work',
|
||||
friendlyName: 'My Work Mac',
|
||||
recentCwds: ['/Users/me/work', '/Users/me/tmp'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('should return only the current user devices, newest lastSeen first', async () => {
|
||||
await deviceModel.register({ deviceId: 'dev-old', identitySource: 'machine-id' });
|
||||
await deviceModel.register({ deviceId: 'dev-new', identitySource: 'machine-id' });
|
||||
// a device owned by another user must not leak
|
||||
await new DeviceModel(serverDB, 'device-model-other-user').register({
|
||||
deviceId: 'other-dev',
|
||||
identitySource: 'machine-id',
|
||||
});
|
||||
|
||||
const list = await deviceModel.query();
|
||||
expect(list.map((d) => d.deviceId)).toEqual(['dev-new', 'dev-old']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update user-editable fields', async () => {
|
||||
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
|
||||
|
||||
await deviceModel.update('dev-1', { friendlyName: 'Renamed' });
|
||||
|
||||
const row = await deviceModel.findByDeviceId('dev-1');
|
||||
expect(row?.friendlyName).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('should not affect another user device with the same deviceId', async () => {
|
||||
await deviceModel.register({ deviceId: 'shared-id', identitySource: 'machine-id' });
|
||||
const other = new DeviceModel(serverDB, 'device-model-other-user');
|
||||
await other.register({ deviceId: 'shared-id', identitySource: 'machine-id' });
|
||||
|
||||
await deviceModel.update('shared-id', { friendlyName: 'Mine' });
|
||||
|
||||
const otherRow = await other.findByDeviceId('shared-id');
|
||||
expect(otherRow?.friendlyName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should remove the row', async () => {
|
||||
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
|
||||
|
||||
await deviceModel.delete('dev-1');
|
||||
|
||||
const row = await deviceModel.findByDeviceId('dev-1');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
|
||||
import type { DeviceItem } from '../schemas';
|
||||
import { devices } from '../schemas';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export interface RegisterDeviceParams {
|
||||
deviceId: string;
|
||||
hostname?: string | null;
|
||||
identitySource: string;
|
||||
platform?: string | null;
|
||||
}
|
||||
|
||||
/** Columns the user owns — never overwritten by an auto-register upsert. */
|
||||
export interface UpdateDeviceParams {
|
||||
defaultCwd?: string | null;
|
||||
friendlyName?: string | null;
|
||||
recentCwds?: string[];
|
||||
}
|
||||
|
||||
export class DeviceModel {
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.userId = userId;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-register from desktop/CLI. Upserts on the (userId, deviceId) unique
|
||||
* index. On conflict only the machine-reported fields + lastSeenAt are
|
||||
* refreshed — friendlyName / defaultCwd / recentCwds are user-owned and
|
||||
* must survive re-registration.
|
||||
*/
|
||||
register = async (params: RegisterDeviceParams) => {
|
||||
const now = new Date();
|
||||
const [result] = await this.db
|
||||
.insert(devices)
|
||||
.values({
|
||||
deviceId: params.deviceId,
|
||||
hostname: params.hostname,
|
||||
identitySource: params.identitySource,
|
||||
lastSeenAt: now,
|
||||
platform: params.platform,
|
||||
userId: this.userId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
hostname: params.hostname,
|
||||
identitySource: params.identitySource,
|
||||
lastSeenAt: now,
|
||||
platform: params.platform,
|
||||
},
|
||||
target: [devices.userId, devices.deviceId],
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
query = async (): Promise<DeviceItem[]> => {
|
||||
return this.db.query.devices.findMany({
|
||||
orderBy: [desc(devices.lastSeenAt)],
|
||||
where: eq(devices.userId, this.userId),
|
||||
});
|
||||
};
|
||||
|
||||
findByDeviceId = async (deviceId: string) => {
|
||||
return this.db.query.devices.findFirst({
|
||||
where: and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)),
|
||||
});
|
||||
};
|
||||
|
||||
update = async (deviceId: string, value: UpdateDeviceParams) => {
|
||||
return this.db
|
||||
.update(devices)
|
||||
.set({ ...value, updatedAt: new Date() })
|
||||
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
|
||||
};
|
||||
|
||||
delete = async (deviceId: string) => {
|
||||
return this.db
|
||||
.delete(devices)
|
||||
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
|
||||
};
|
||||
}
|
||||
@@ -200,7 +200,7 @@ const OptionRow = memo<OptionRowProps>(({ active, desc, disabled, icon, label, o
|
||||
|
||||
OptionRow.displayName = 'HeteroDeviceSwitcher.OptionRow';
|
||||
|
||||
const getDeviceIcon = (platform: string | undefined, size = 14): ReactNode => {
|
||||
const getDeviceIcon = (platform: string | null | undefined, size = 14): ReactNode => {
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
return <SiApple color="currentColor" size={size} />;
|
||||
@@ -278,7 +278,10 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
chipLabel = t('heteroAgent.executionTarget.local');
|
||||
} else if (executionTarget === 'device') {
|
||||
chipIcon = getDeviceIcon(boundDevice?.platform);
|
||||
chipLabel = boundDevice?.hostname ?? t('heteroAgent.executionTarget.unknownDevice');
|
||||
chipLabel =
|
||||
boundDevice?.friendlyName ??
|
||||
boundDevice?.hostname ??
|
||||
t('heteroAgent.executionTarget.unknownDevice');
|
||||
}
|
||||
|
||||
const isActive = (target: HeteroExecutionTarget, deviceId?: string) => {
|
||||
@@ -318,7 +321,7 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
disabled={!d.online}
|
||||
icon={getDeviceIcon(d.platform)}
|
||||
key={d.deviceId}
|
||||
label={d.hostname}
|
||||
label={d.friendlyName || d.hostname || d.deviceId}
|
||||
desc={
|
||||
<>
|
||||
<span className={d.online ? styles.dotOnline : styles.dotOffline} />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DeviceModel } from '@/database/models/device';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { deviceProxy } from '@/server/services/toolExecution/deviceProxy';
|
||||
|
||||
// Derive the zod enum from the canonical config so new platforms are
|
||||
@@ -16,11 +18,11 @@ const remotePlatformEnum = z.enum(
|
||||
const CAPABILITY_TIMEOUT_MS = 5_000;
|
||||
const PROFILE_TIMEOUT_MS = 5_000;
|
||||
|
||||
const deviceProcedure = authedProcedure.use(async (opts) => {
|
||||
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
return opts.next({
|
||||
ctx: { userId: ctx.userId },
|
||||
ctx: { deviceModel: new DeviceModel(ctx.serverDB, ctx.userId), userId: ctx.userId },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,11 +107,102 @@ export const deviceRouter = router({
|
||||
return deviceProxy.queryDeviceSystemInfo(ctx.userId, input.deviceId);
|
||||
}),
|
||||
|
||||
/**
|
||||
* All devices the user has ever registered (incl. offline), each enriched
|
||||
* with a live `online` flag from the gateway's in-memory WS sessions.
|
||||
*
|
||||
* A union, not just the DB rows: a device may be connected but not yet in
|
||||
* the DB (old client that predates auto-register, or registration still in
|
||||
* flight). Those are surfaced as transient entries so the picker never loses
|
||||
* a currently-reachable device during rollout.
|
||||
*/
|
||||
listDevices: deviceProcedure.query(async ({ ctx }) => {
|
||||
return deviceProxy.queryDeviceList(ctx.userId);
|
||||
const [registered, onlineList] = await Promise.all([
|
||||
ctx.deviceModel.query(),
|
||||
deviceProxy.queryDeviceList(ctx.userId),
|
||||
]);
|
||||
|
||||
const onlineMap = new Map(onlineList.map((d) => [d.deviceId, d]));
|
||||
const seen = new Set<string>();
|
||||
|
||||
const fromDb = registered.map((d) => {
|
||||
seen.add(d.deviceId);
|
||||
const live = onlineMap.get(d.deviceId);
|
||||
return {
|
||||
defaultCwd: d.defaultCwd,
|
||||
deviceId: d.deviceId,
|
||||
friendlyName: d.friendlyName,
|
||||
hostname: d.hostname ?? live?.hostname ?? null,
|
||||
identitySource: d.identitySource,
|
||||
lastSeen: d.lastSeenAt.toISOString(),
|
||||
online: onlineMap.has(d.deviceId),
|
||||
platform: d.platform ?? live?.platform ?? null,
|
||||
recentCwds: d.recentCwds,
|
||||
registered: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Online but not yet persisted — transient until the client auto-registers.
|
||||
const ghosts = onlineList
|
||||
.filter((d) => !seen.has(d.deviceId))
|
||||
.map((d) => ({
|
||||
defaultCwd: null,
|
||||
deviceId: d.deviceId,
|
||||
friendlyName: null,
|
||||
hostname: d.hostname ?? null,
|
||||
identitySource: null,
|
||||
lastSeen: d.lastSeen,
|
||||
online: true,
|
||||
platform: d.platform ?? null,
|
||||
recentCwds: [] as string[],
|
||||
registered: false,
|
||||
}));
|
||||
|
||||
return [...fromDb, ...ghosts];
|
||||
}),
|
||||
|
||||
/**
|
||||
* Auto-register the calling device (desktop after OIDC login / CLI on first
|
||||
* `lh connect`). Upserts on (userId, deviceId); user-owned fields are
|
||||
* preserved on conflict.
|
||||
*/
|
||||
register: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string().min(1).max(64),
|
||||
hostname: z.string().nullable().optional(),
|
||||
identitySource: z.enum(['machine-id', 'fallback']),
|
||||
platform: z.string().max(20).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.deviceModel.register(input);
|
||||
}),
|
||||
|
||||
removeDevice: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.deviceModel.delete(input.deviceId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
status: deviceProcedure.query(async ({ ctx }) => {
|
||||
return deviceProxy.queryDeviceStatus(ctx.userId);
|
||||
}),
|
||||
|
||||
/** User-editable fields only — never the machine-reported identity columns. */
|
||||
updateDevice: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
defaultCwd: z.string().nullable().optional(),
|
||||
deviceId: z.string(),
|
||||
friendlyName: z.string().max(100).nullable().optional(),
|
||||
recentCwds: z.array(z.string()).max(20).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { deviceId, ...value } = input;
|
||||
await ctx.deviceModel.update(deviceId, value);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user