Remove the API relay link and the ESP32 device feature
Two pieces of upstream promotion came out of this deployment. The sidebar "apiRelay" tab only opened a registration page on a third-party site, so it goes with no replacement. The circuit badge next to Settings was not what it looked like. It opened an ESP32 device manager: add a device, show LAN status, remote connect/disconnect, rename, delete, backed by six REST routes and an mcu_devices table. The purchase button inside that dialog was the promotional part; the rest was real device management. Since the hardware is out of scope here, the entire feature is removed rather than just the link that pointed upstream - UI entry, client API, routes, controller, store, schema, locale strings, and the one GlobalAgentServer method that only existed to answer its status column. Table creation is dropped but no DROP TABLE was added, so an already-provisioned database keeps its rows as harmless orphans instead of losing data on upgrade. DevicesView and the hermes.devices route are untouched: that is LAN device discovery, a separate feature. AGENTS.md now records the removal so the badge does not come back.
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export interface McuDevice {
|
||||
id: number
|
||||
name: string
|
||||
device_code: string
|
||||
is_official: boolean
|
||||
created_at: number
|
||||
lan_connected?: boolean
|
||||
remote_connected?: boolean
|
||||
}
|
||||
|
||||
export interface McuDeviceListResponse {
|
||||
devices: McuDevice[]
|
||||
}
|
||||
|
||||
export async function fetchMcuDevices(): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>('/api/mcu-devices')
|
||||
}
|
||||
|
||||
export async function createMcuDevice(input: {
|
||||
name: string
|
||||
device_code: string
|
||||
}): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>('/api/mcu-devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateMcuDeviceName(id: number, name: string): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>(`/api/mcu-devices/${encodeURIComponent(String(id))}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteMcuDevice(id: number): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>(`/api/mcu-devices/${encodeURIComponent(String(id))}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function connectMcuDeviceRemote(id: number): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>(`/api/mcu-devices/${encodeURIComponent(String(id))}/remote-connect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function disconnectMcuDeviceRemote(id: number): Promise<McuDeviceListResponse> {
|
||||
return request<McuDeviceListResponse>(`/api/mcu-devices/${encodeURIComponent(String(id))}/remote-disconnect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import OutlinePanel from "./OutlinePanel.vue";
|
||||
import FilesPanel from "./FilesPanel.vue";
|
||||
import TerminalPanel from "./TerminalPanel.vue";
|
||||
import PageSidebarNav from "@/components/layout/PageSidebarNav.vue";
|
||||
import SettingsCircuitBadge from "@/components/layout/SettingsCircuitBadge.vue";
|
||||
import { isStoredSuperAdmin } from "@/api/client";
|
||||
import { useDefaultWorkspace } from "@/composables/useDefaultWorkspace";
|
||||
|
||||
@@ -1289,7 +1288,6 @@ async function handleSessionModelCustomSubmit() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.settings") }}</span>
|
||||
</button>
|
||||
<SettingsCircuitBadge />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import GroupChatInput from './GroupChatInput.vue'
|
||||
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
|
||||
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||
import PageSidebarNav from '@/components/layout/PageSidebarNav.vue'
|
||||
import SettingsCircuitBadge from '@/components/layout/SettingsCircuitBadge.vue'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
import type { RoomAgent, RoomInfo } from '@/api/hermes/group-chat'
|
||||
@@ -512,7 +511,6 @@ async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') {
|
||||
</svg>
|
||||
<span>{{ t('sidebar.settings') }}</span>
|
||||
</button>
|
||||
<SettingsCircuitBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import SettingsCircuitBadge from '@/components/layout/SettingsCircuitBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -20,7 +19,6 @@ function openSettingsPage() {
|
||||
</svg>
|
||||
<span>{{ t('sidebar.settings') }}</span>
|
||||
</button>
|
||||
<SettingsCircuitBadge />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -48,11 +48,6 @@ function openWorkflow() {
|
||||
if (props.active === 'workflow') return
|
||||
void router.push({ name: 'hermes.workflow' })
|
||||
}
|
||||
|
||||
function openApiRelay() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.open('https://apikey.fun/register?aff=LIBAPI', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,21 +119,6 @@ function openApiRelay() {
|
||||
</svg>
|
||||
<span>{{ historyButtonLabel }}</span>
|
||||
</button>
|
||||
<button class="page-sidebar-tab" type="button" @click="openApiRelay">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
<span>{{ t('sidebar.apiRelay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showModeSwitch" class="conversation-switch conversation-switch--three" role="tablist" aria-label="Conversation type">
|
||||
<a-tooltip trigger="hover" position="top" :content="t('sidebar.singleChat')">
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, resolveComponent } from 'vue'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { connectMcuDeviceRemote, createMcuDevice, deleteMcuDevice, disconnectMcuDeviceRemote, fetchMcuDevices, updateMcuDeviceName, type McuDevice } from '@/api/hermes/mcu-devices'
|
||||
|
||||
// a-table 的 column.render 里不能用 kebab-case 标签(h('a-tag') 只会生成
|
||||
// 同名原生元素,不会解析全局注册的组件),必须走 resolveComponent。
|
||||
const Tag = resolveComponent('a-tag')
|
||||
const Button = resolveComponent('a-button')
|
||||
const Space = resolveComponent('a-space')
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const purchaseUrl = 'https://hermes-studio.ai/docs/hermes-esp32-intro/index.html'
|
||||
const showModal = ref(false)
|
||||
const showAddModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const connectingDeviceId = ref(0)
|
||||
const editing = ref(false)
|
||||
const devices = ref<McuDevice[]>([])
|
||||
const form = ref({
|
||||
name: '',
|
||||
device_code: '',
|
||||
})
|
||||
const editForm = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
})
|
||||
|
||||
function renderConnectionStatus(connected: boolean | undefined) {
|
||||
return h(Tag, {
|
||||
size: 'small',
|
||||
color: connected ? 'green' : 'gray',
|
||||
bordered: false,
|
||||
}, { default: () => connected ? t('mcuDevices.connected') : t('mcuDevices.disconnected') })
|
||||
}
|
||||
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{
|
||||
title: t('mcuDevices.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: t('mcuDevices.deviceCode'),
|
||||
dataIndex: 'device_code',
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: t('mcuDevices.channel'),
|
||||
dataIndex: 'is_official',
|
||||
width: 96,
|
||||
render({ record }) {
|
||||
const row = record as McuDevice
|
||||
return h(Tag, {
|
||||
size: 'small',
|
||||
color: row.is_official ? 'green' : 'orange',
|
||||
bordered: false,
|
||||
}, { default: () => row.is_official ? t('mcuDevices.official') : t('mcuDevices.unofficial') })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('mcuDevices.lanStatus'),
|
||||
dataIndex: 'lan_connected',
|
||||
width: 96,
|
||||
render({ record }) {
|
||||
return renderConnectionStatus((record as McuDevice).lan_connected)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('mcuDevices.remoteStatus'),
|
||||
dataIndex: 'remote_connected',
|
||||
width: 96,
|
||||
render({ record }) {
|
||||
return renderConnectionStatus((record as McuDevice).remote_connected)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('mcuDevices.actions'),
|
||||
dataIndex: 'actions',
|
||||
width: 220,
|
||||
render({ record }) {
|
||||
const row = record as McuDevice
|
||||
return h(Space, { size: 4 }, {
|
||||
default: () => [
|
||||
h(Button, {
|
||||
size: 'mini',
|
||||
type: 'text',
|
||||
onClick: () => openEditModal(row),
|
||||
}, { default: () => t('mcuDevices.edit') }),
|
||||
h(Button, {
|
||||
size: 'mini',
|
||||
type: 'text',
|
||||
status: row.remote_connected ? 'warning' : 'normal',
|
||||
loading: connectingDeviceId.value === row.id,
|
||||
disabled: connectingDeviceId.value !== 0 || (!row.is_official && !row.remote_connected),
|
||||
onClick: () => toggleRemoteDevice(row),
|
||||
}, { default: () => row.remote_connected ? t('mcuDevices.remoteDisconnect') : t('mcuDevices.remoteConnect') }),
|
||||
h(Button, {
|
||||
size: 'mini',
|
||||
type: 'text',
|
||||
status: 'danger',
|
||||
onClick: () => confirmDeleteDevice(row),
|
||||
}, { default: () => t('mcuDevices.delete') }),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchMcuDevices()
|
||||
devices.value = response.devices
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('mcuDevices.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
showModal.value = true
|
||||
void loadDevices()
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
form.value = { name: '', device_code: '' }
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function openPurchasePage() {
|
||||
window.open(purchaseUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function submitDevice(): Promise<boolean | void> {
|
||||
const deviceCode = form.value.device_code.trim()
|
||||
if (!deviceCode) {
|
||||
message.warning(t('mcuDevices.deviceCodeRequired'))
|
||||
return false
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await createMcuDevice({
|
||||
name: form.value.name.trim(),
|
||||
device_code: deviceCode,
|
||||
})
|
||||
devices.value = response.devices
|
||||
form.value = { name: '', device_code: '' }
|
||||
showAddModal.value = false
|
||||
message.success(t('mcuDevices.added'))
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('mcuDevices.addFailed'))
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(device: McuDevice) {
|
||||
editForm.value = {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.value.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await updateMcuDeviceName(editForm.value.id, editForm.value.name)
|
||||
devices.value = response.devices
|
||||
editing.value = false
|
||||
message.success(t('mcuDevices.nameUpdated'))
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('mcuDevices.nameUpdateFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRemoteDevice(device: McuDevice) {
|
||||
connectingDeviceId.value = device.id
|
||||
try {
|
||||
const response = device.remote_connected
|
||||
? await disconnectMcuDeviceRemote(device.id)
|
||||
: await connectMcuDeviceRemote(device.id)
|
||||
devices.value = response.devices
|
||||
message.success(t(device.remote_connected ? 'mcuDevices.remoteDisconnected' : 'mcuDevices.remoteConnected'))
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t(device.remote_connected ? 'mcuDevices.remoteDisconnectFailed' : 'mcuDevices.remoteConnectFailed'))
|
||||
} finally {
|
||||
connectingDeviceId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteDevice(device: McuDevice) {
|
||||
dialog.warning({
|
||||
title: t('mcuDevices.deleteTitle'),
|
||||
content: t('mcuDevices.deleteConfirm', { name: device.name || device.device_code }),
|
||||
positiveText: t('mcuDevices.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await deleteMcuDevice(device.id)
|
||||
devices.value = response.devices
|
||||
message.success(t('mcuDevices.deleted'))
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('mcuDevices.deleteFailed'))
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Style 1: chip badge
|
||||
<svg class="settings-circuit-badge settings-circuit-badge--chip" viewBox="0 0 34 22" fill="none" aria-hidden="true">
|
||||
<rect class="settings-circuit-badge-shell" x="4.5" y="3.5" width="25" height="15" rx="4" />
|
||||
<path class="settings-circuit-pin" d="M2.5 7h3M2.5 11h3M2.5 15h3M28.5 7h3M28.5 11h3M28.5 15h3" />
|
||||
<path class="settings-circuit-pin" d="M11 1.8v2M17 1.8v2M23 1.8v2M11 18.2v2M17 18.2v2M23 18.2v2" />
|
||||
<rect class="settings-circuit-chip" x="12" y="6" width="10" height="10" rx="2" />
|
||||
<path class="settings-circuit-track" d="M17 11H7" />
|
||||
<path class="settings-circuit-track" d="M17 11h10" />
|
||||
<path class="settings-circuit-track" d="M17 11V5" />
|
||||
<path class="settings-circuit-track" d="M17 11v6" />
|
||||
<path class="settings-circuit-track" d="M14 8h-4V6H7" />
|
||||
<path class="settings-circuit-track" d="M20 8h4V6h3" />
|
||||
<path class="settings-circuit-track" d="M14 14h-4v2H7" />
|
||||
<path class="settings-circuit-track" d="M20 14h4v2h3" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-main" d="M17 11H7" pathLength="1" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-main settings-circuit-pulse-late" d="M17 11h10" pathLength="1" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-branch" d="M17 11V5" pathLength="1" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-branch settings-circuit-pulse-late" d="M17 11v6" pathLength="1" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-branch" d="M14 8h-4V6H7" pathLength="1" />
|
||||
<path class="settings-circuit-pulse settings-circuit-pulse-branch settings-circuit-pulse-late" d="M20 14h4v2h3" pathLength="1" />
|
||||
<circle class="settings-circuit-core" cx="17" cy="11" r="1.35" />
|
||||
<circle class="settings-circuit-node" cx="7" cy="6" r="0.9" />
|
||||
<circle class="settings-circuit-node" cx="27" cy="6" r="0.9" />
|
||||
<circle class="settings-circuit-node" cx="7" cy="16" r="0.9" />
|
||||
<circle class="settings-circuit-node" cx="27" cy="16" r="0.9" />
|
||||
</svg>
|
||||
-->
|
||||
<a-tooltip trigger="hover" position="top" :content="t('mcuDevices.title')">
|
||||
<button class="settings-circuit-link" type="button" :aria-label="t('mcuDevices.title')" @click.stop="openModal">
|
||||
<svg class="settings-circuit-badge settings-circuit-badge--pcb" viewBox="0 0 36 22" fill="none" aria-hidden="true">
|
||||
<rect class="settings-pcb-board" x="3" y="3" width="30" height="16" rx="3" />
|
||||
<path class="settings-pcb-copper" d="M7 7h6v4h7v-3h9" />
|
||||
<path class="settings-pcb-copper" d="M7 15h5v-3h5v4h12" />
|
||||
<path class="settings-pcb-copper" d="M12 7v-2h8" />
|
||||
<path class="settings-pcb-copper" d="M24 15v2h5" />
|
||||
<path class="settings-pcb-current settings-pcb-current-main" d="M7 7h6v4h7v-3h9" />
|
||||
<path class="settings-pcb-current settings-pcb-current-main settings-pcb-current-late" d="M7 15h5v-3h5v4h12" />
|
||||
<path class="settings-pcb-current settings-pcb-current-branch" d="M12 7v-2h8" />
|
||||
<path class="settings-pcb-current settings-pcb-current-branch settings-pcb-current-late" d="M24 15v2h5" />
|
||||
<circle class="settings-pcb-pad settings-pcb-pad-live" cx="7" cy="7" r="1.2" />
|
||||
<circle class="settings-pcb-pad" cx="13" cy="11" r="1" />
|
||||
<circle class="settings-pcb-pad settings-pcb-pad-live" cx="20" cy="11" r="1.2" />
|
||||
<circle class="settings-pcb-pad" cx="29" cy="8" r="1" />
|
||||
<circle class="settings-pcb-pad settings-pcb-pad-live settings-pcb-pad-late" cx="17" cy="12" r="1.2" />
|
||||
<circle class="settings-pcb-pad" cx="29" cy="15" r="1" />
|
||||
<circle class="settings-pcb-via" cx="20" cy="5" r="0.8" />
|
||||
<circle class="settings-pcb-via" cx="29" cy="17" r="0.8" />
|
||||
</svg>
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-modal v-model:visible="showModal" :footer="false" :closable="false">
|
||||
<div class="mcu-device-dialog">
|
||||
<div class="mcu-device-header">
|
||||
<div>
|
||||
<div class="mcu-device-title">{{ t('mcuDevices.title') }}</div>
|
||||
<div class="mcu-device-subtitle">{{ t('mcuDevices.subtitle') }}</div>
|
||||
</div>
|
||||
<button class="mcu-device-close" type="button" :aria-label="t('mcuDevices.close')" @click="showModal = false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mcu-device-table">
|
||||
<a-table
|
||||
style="height: 100%;"
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="devices"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ y: '100%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mcu-device-actions">
|
||||
<a-button type="primary" @click="openAddModal">{{ t('mcuDevices.add') }}</a-button>
|
||||
<a-button type="secondary" @click="openPurchasePage">{{ t('mcuDevices.purchase') }}</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
<a-modal
|
||||
v-model:visible="showAddModal"
|
||||
:title="t('mcuDevices.addTitle')"
|
||||
:ok-text="t('mcuDevices.add')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:ok-loading="saving"
|
||||
@before-ok="submitDevice"
|
||||
>
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item :label="t('mcuDevices.name')">
|
||||
<a-input v-model="form.name" :placeholder="t('mcuDevices.nameOptional')" :disabled="saving" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('mcuDevices.deviceCode')" required>
|
||||
<a-input
|
||||
v-model="form.device_code"
|
||||
:placeholder="t('mcuDevices.deviceCodePlaceholder')"
|
||||
:disabled="saving"
|
||||
@keydown.enter.prevent="submitDevice"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<a-modal
|
||||
v-model:visible="editing"
|
||||
:title="t('mcuDevices.editNameTitle')"
|
||||
:ok-text="t('common.save')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:ok-loading="saving"
|
||||
@before-ok="submitEdit"
|
||||
>
|
||||
<a-input
|
||||
v-model="editForm.name"
|
||||
:placeholder="t('mcuDevices.nameOptional')"
|
||||
:disabled="saving"
|
||||
@keydown.enter.prevent="submitEdit"
|
||||
/>
|
||||
</a-modal>
|
||||
<!-- Style 3: current scan line
|
||||
<svg class="settings-circuit-badge settings-circuit-badge--scan" viewBox="0 0 36 20" fill="none" aria-hidden="true">
|
||||
<path class="settings-scan-track" d="M3 10h6l3-5h7l3 10h5l3-5h3" />
|
||||
<path class="settings-scan-track settings-scan-branch" d="M12 5V3h6" />
|
||||
<path class="settings-scan-track settings-scan-branch" d="M22 15v2h7" />
|
||||
<path class="settings-scan-glow" d="M3 10h6l3-5h7l3 10h5l3-5h3" pathLength="1" />
|
||||
<path class="settings-scan-glow settings-scan-glow-branch" d="M12 5V3h6" pathLength="1" />
|
||||
<path class="settings-scan-glow settings-scan-glow-branch settings-scan-glow-late" d="M22 15v2h7" pathLength="1" />
|
||||
<circle class="settings-scan-node settings-scan-node-live" cx="12" cy="5" r="1.2" />
|
||||
<circle class="settings-scan-node" cx="19" cy="5" r="0.9" />
|
||||
<circle class="settings-scan-node settings-scan-node-live settings-scan-node-late" cx="22" cy="15" r="1.2" />
|
||||
<circle class="settings-scan-node" cx="30" cy="10" r="0.9" />
|
||||
</svg>
|
||||
-->
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-circuit-badge {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
color: #d6a019;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 4px rgba(214, 160, 25, 0.45));
|
||||
}
|
||||
|
||||
.settings-circuit-link {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(214, 160, 25, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.mcu-device-dialog {
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
height: min(680px, calc(100vh - 72px));
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcu-device-header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 68px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mcu-device-title {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mcu-device-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mcu-device-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--text-muted-rgb), 0.14);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mcu-device-actions {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mcu-device-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 12px 18px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.arco-table-container) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.arco-table-body) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.arco-table-th) {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.arco-table-td) {
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mcu-device-dialog {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.mcu-device-actions {
|
||||
justify-content: stretch;
|
||||
|
||||
:deep(.arco-btn) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-circuit-badge--scan {
|
||||
height: 18px;
|
||||
color: #d6a019;
|
||||
filter:
|
||||
drop-shadow(0 0 3px rgba(214, 160, 25, 0.55))
|
||||
drop-shadow(0 0 8px rgba(214, 160, 25, 0.22));
|
||||
}
|
||||
|
||||
.settings-scan-track,
|
||||
.settings-scan-glow {
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.settings-scan-track {
|
||||
stroke-width: 1.25;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.settings-scan-branch {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.settings-scan-glow {
|
||||
stroke-width: 1.85;
|
||||
stroke-dasharray: 4 18;
|
||||
animation: settings-circuit-flow 1.05s linear infinite;
|
||||
}
|
||||
|
||||
.settings-scan-glow-branch {
|
||||
stroke-width: 1.55;
|
||||
animation-duration: 1.65s;
|
||||
}
|
||||
|
||||
.settings-scan-glow-late {
|
||||
animation-delay: -0.55s;
|
||||
}
|
||||
|
||||
.settings-scan-node {
|
||||
fill: rgba(214, 160, 25, 0.38);
|
||||
stroke: currentColor;
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
|
||||
.settings-scan-node-live {
|
||||
fill: currentColor;
|
||||
animation: settings-circuit-node 1.05s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.settings-scan-node-late {
|
||||
animation-delay: -0.55s;
|
||||
}
|
||||
|
||||
.settings-pcb-board {
|
||||
fill: rgba(214, 160, 25, 0.045);
|
||||
stroke: rgba(214, 160, 25, 0.46);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.settings-pcb-copper,
|
||||
.settings-pcb-current {
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.settings-pcb-copper {
|
||||
stroke-width: 1.15;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.settings-pcb-current {
|
||||
stroke-width: 1.75;
|
||||
stroke-dasharray: 4 16;
|
||||
animation: settings-circuit-flow 1.35s linear infinite;
|
||||
}
|
||||
|
||||
.settings-pcb-current-branch {
|
||||
stroke-dasharray: 3 13;
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
|
||||
.settings-pcb-current-late {
|
||||
animation-delay: -0.65s;
|
||||
}
|
||||
|
||||
.settings-pcb-pad,
|
||||
.settings-pcb-via {
|
||||
fill: rgba(214, 160, 25, 0.34);
|
||||
stroke: currentColor;
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
|
||||
.settings-pcb-via {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-pcb-pad-live {
|
||||
fill: currentColor;
|
||||
animation: settings-circuit-node 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.settings-pcb-pad-late {
|
||||
animation-delay: -0.65s;
|
||||
}
|
||||
|
||||
.settings-circuit-badge-shell {
|
||||
fill: rgba(214, 160, 25, 0.1);
|
||||
stroke: rgba(214, 160, 25, 0.55);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.settings-circuit-chip {
|
||||
fill: rgba(214, 160, 25, 0.18);
|
||||
stroke: rgba(255, 220, 125, 0.75);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.settings-circuit-pin,
|
||||
.settings-circuit-track,
|
||||
.settings-circuit-pulse {
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.settings-circuit-pin {
|
||||
stroke-width: 1.1;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.settings-circuit-track {
|
||||
stroke-width: 1;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.settings-circuit-pulse {
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 4 16;
|
||||
animation: settings-circuit-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.settings-circuit-pulse-branch {
|
||||
stroke-dasharray: 3 13;
|
||||
animation-duration: 1.9s;
|
||||
}
|
||||
|
||||
.settings-circuit-pulse-late {
|
||||
animation-delay: -0.65s;
|
||||
}
|
||||
|
||||
.settings-circuit-core,
|
||||
.settings-circuit-node {
|
||||
fill: currentColor;
|
||||
opacity: 0.9;
|
||||
animation: settings-circuit-node 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.settings-circuit-core {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 220, 125, 0.9));
|
||||
}
|
||||
|
||||
@keyframes settings-circuit-flow {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settings-circuit-node {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-scan-glow,
|
||||
.settings-scan-node-live,
|
||||
.settings-pcb-current,
|
||||
.settings-pcb-pad-live,
|
||||
.settings-circuit-pulse,
|
||||
.settings-circuit-node {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -194,7 +194,6 @@ export default {
|
||||
chat: 'Chat',
|
||||
backToChat: 'Back',
|
||||
search: 'Search',
|
||||
apiRelay: 'API Relay',
|
||||
history: 'History',
|
||||
jobs: 'Jobs',
|
||||
kanban: 'Kanban',
|
||||
@@ -310,47 +309,6 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
mcuDevices: {
|
||||
title: 'Little Box',
|
||||
subtitle: 'View device link status',
|
||||
name: 'Name',
|
||||
nameOptional: 'Name is optional',
|
||||
deviceCode: 'Device code',
|
||||
deviceCodePlaceholder: 'Enter device code',
|
||||
deviceCodeRequired: 'Enter device code',
|
||||
channel: 'Channel',
|
||||
official: 'Official',
|
||||
unofficial: 'Unofficial',
|
||||
createdAt: 'Added at',
|
||||
lanStatus: 'LAN',
|
||||
remoteStatus: 'Remote',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
remoteConnect: 'Remote connect',
|
||||
remoteConnected: 'Remote connected',
|
||||
remoteConnectFailed: 'Failed to connect remote',
|
||||
remoteDisconnect: 'Disconnect',
|
||||
remoteDisconnected: 'Remote disconnected',
|
||||
remoteDisconnectFailed: 'Failed to disconnect remote',
|
||||
actions: 'Actions',
|
||||
add: 'Add',
|
||||
addTitle: 'Add MCU device',
|
||||
added: 'MCU device added',
|
||||
addFailed: 'Failed to add MCU device',
|
||||
purchase: 'Buy',
|
||||
edit: 'Edit',
|
||||
editNameTitle: 'Edit name',
|
||||
nameUpdated: 'Name updated',
|
||||
nameUpdateFailed: 'Failed to update name',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete MCU device',
|
||||
deleteConfirm: 'Delete "{name}"?',
|
||||
deleted: 'MCU device deleted',
|
||||
deleteFailed: 'Failed to delete MCU device',
|
||||
loadFailed: 'Failed to load MCU devices',
|
||||
close: 'Close',
|
||||
},
|
||||
|
||||
devices: {
|
||||
title: 'Devices',
|
||||
count: '{count} devices',
|
||||
|
||||
@@ -194,7 +194,6 @@ export default {
|
||||
chat: '对话',
|
||||
backToChat: '返回',
|
||||
search: '搜索',
|
||||
apiRelay: '饲料',
|
||||
history: '历史',
|
||||
jobs: '任务',
|
||||
kanban: '看板',
|
||||
@@ -310,47 +309,6 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
mcuDevices: {
|
||||
title: '小方盒',
|
||||
subtitle: '查看设备链接状态',
|
||||
name: '名称',
|
||||
nameOptional: '名称可为空',
|
||||
deviceCode: '设备码',
|
||||
deviceCodePlaceholder: '请输入设备码',
|
||||
deviceCodeRequired: '请输入设备码',
|
||||
channel: '渠道',
|
||||
official: '官方',
|
||||
unofficial: '非官方',
|
||||
createdAt: '添加时间',
|
||||
lanStatus: '局域网',
|
||||
remoteStatus: '远程',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
remoteConnect: '远程连接',
|
||||
remoteConnected: '远程已连接',
|
||||
remoteConnectFailed: '远程连接失败',
|
||||
remoteDisconnect: '断开连接',
|
||||
remoteDisconnected: '远程已断开',
|
||||
remoteDisconnectFailed: '远程断开失败',
|
||||
actions: '操作',
|
||||
add: '添加',
|
||||
addTitle: '添加 MCU 设备',
|
||||
added: 'MCU 设备已添加',
|
||||
addFailed: '添加 MCU 设备失败',
|
||||
purchase: '购买',
|
||||
edit: '编辑',
|
||||
editNameTitle: '编辑名称',
|
||||
nameUpdated: '名称已更新',
|
||||
nameUpdateFailed: '更新名称失败',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除 MCU 设备',
|
||||
deleteConfirm: '确定删除「{name}」吗?',
|
||||
deleted: 'MCU 设备已删除',
|
||||
deleteFailed: '删除 MCU 设备失败',
|
||||
loadFailed: '加载 MCU 设备失败',
|
||||
close: '关闭',
|
||||
},
|
||||
|
||||
devices: {
|
||||
title: '设备',
|
||||
count: '{count} 台设备',
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { Context } from 'koa'
|
||||
import { config } from '../config'
|
||||
import { getLanEndpointKind } from '../services/lan-discovery'
|
||||
import { getPublicSystemInfo } from '../services/system-info'
|
||||
import { getOutboundRelayClient, startOutboundRelayClient, stopOutboundRelayClient } from '../services/global-agent/outbound-relay-client'
|
||||
import { getGlobalAgentServer } from '../services/global-agent/server'
|
||||
import { createMcuDevice, deleteMcuDevice, getMcuDevice, listMcuDevices, updateMcuDeviceName, type McuDeviceRecord } from '../db/hermes/mcu-devices-store'
|
||||
|
||||
function normalizeDeviceCode(value: unknown): string {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized.length <= 255 ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizeName(value: unknown, deviceCode: string): string {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized.length > 80) return normalized.slice(0, 80)
|
||||
return normalized || deviceCode
|
||||
}
|
||||
|
||||
async function verifyOfficialDeviceCode(deviceCode: string): Promise<boolean> {
|
||||
try {
|
||||
const url = `${config.remoteRelay.url.replace(/\/$/, '')}/global-agent/device/${encodeURIComponent(deviceCode)}`
|
||||
const response = await fetch(url, { method: 'GET' })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function remoteConnectionId(deviceCode: string): string {
|
||||
return `mcu-device:${deviceCode}`
|
||||
}
|
||||
|
||||
function requestToken(ctx: Context): string {
|
||||
const auth = ctx.headers.authorization || ''
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim()
|
||||
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
|
||||
}
|
||||
|
||||
function requestBaseUrl(ctx: Context): string | undefined {
|
||||
const host = ctx.get('host').trim()
|
||||
if (!host) return undefined
|
||||
return `${ctx.protocol || 'http'}://${host}`
|
||||
}
|
||||
|
||||
function localBaseUrl(): string {
|
||||
return `http://127.0.0.1:${config.port}`
|
||||
}
|
||||
|
||||
async function localRelayMachineInfo(url: string) {
|
||||
const info = await getPublicSystemInfo()
|
||||
return {
|
||||
...info,
|
||||
http_port: config.port,
|
||||
endpoint_kind: getLanEndpointKind(config.port),
|
||||
url,
|
||||
relay_url: config.remoteRelay.url,
|
||||
}
|
||||
}
|
||||
|
||||
function withMcuDeviceStatus(device: McuDeviceRecord) {
|
||||
const remoteClient = getOutboundRelayClient(remoteConnectionId(device.device_code))
|
||||
return {
|
||||
...device,
|
||||
lan_connected: Boolean(getGlobalAgentServer()?.hasMcuDeviceCode(device.device_code)),
|
||||
remote_connected: Boolean(remoteClient?.isConnected()),
|
||||
}
|
||||
}
|
||||
|
||||
function listMcuDevicesWithStatus() {
|
||||
return listMcuDevices().map(withMcuDeviceStatus)
|
||||
}
|
||||
|
||||
export async function listMcuDevicesController(ctx: Context) {
|
||||
ctx.body = { devices: listMcuDevicesWithStatus() }
|
||||
}
|
||||
|
||||
export async function createMcuDeviceController(ctx: Context) {
|
||||
const body = ctx.request.body as { name?: unknown; device_code?: unknown; deviceCode?: unknown } | undefined
|
||||
const deviceCode = normalizeDeviceCode(body?.device_code ?? body?.deviceCode)
|
||||
if (!deviceCode) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'device_code is required' }
|
||||
return
|
||||
}
|
||||
|
||||
const isOfficial = await verifyOfficialDeviceCode(deviceCode)
|
||||
|
||||
try {
|
||||
const device = createMcuDevice({
|
||||
name: normalizeName(body?.name, deviceCode),
|
||||
deviceCode,
|
||||
isOfficial,
|
||||
})
|
||||
ctx.status = 201
|
||||
ctx.body = {
|
||||
device: withMcuDeviceStatus(device),
|
||||
devices: listMcuDevicesWithStatus(),
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'mcu_device_exists') {
|
||||
ctx.status = 409
|
||||
ctx.body = { error: 'MCU device already exists' }
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMcuDeviceController(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'invalid mcu device id' }
|
||||
return
|
||||
}
|
||||
|
||||
const body = ctx.request.body as { name?: unknown } | undefined
|
||||
try {
|
||||
const device = updateMcuDeviceName(id, String(body?.name || ''))
|
||||
ctx.body = {
|
||||
device: withMcuDeviceStatus(device),
|
||||
devices: listMcuDevicesWithStatus(),
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'mcu_device_not_found') {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'MCU device not found' }
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMcuDeviceController(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'invalid mcu device id' }
|
||||
return
|
||||
}
|
||||
|
||||
const deleted = deleteMcuDevice(id)
|
||||
if (!deleted) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'MCU device not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { devices: listMcuDevicesWithStatus() }
|
||||
}
|
||||
|
||||
export async function connectMcuDeviceRemoteController(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'invalid mcu device id' }
|
||||
return
|
||||
}
|
||||
|
||||
const device = getMcuDevice(id)
|
||||
if (!device) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'MCU device not found' }
|
||||
return
|
||||
}
|
||||
|
||||
if (!await verifyOfficialDeviceCode(device.device_code)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: '非官方设备码' }
|
||||
return
|
||||
}
|
||||
|
||||
const userToken = requestToken(ctx)
|
||||
if (!userToken) {
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
return
|
||||
}
|
||||
|
||||
const connectionId = remoteConnectionId(device.device_code)
|
||||
stopOutboundRelayClient(connectionId)
|
||||
const baseUrl = localBaseUrl()
|
||||
const publicBaseUrl = requestBaseUrl(ctx) || baseUrl
|
||||
const machineInfo = await localRelayMachineInfo(publicBaseUrl)
|
||||
const client = startOutboundRelayClient({
|
||||
connectionId,
|
||||
relayUrl: config.remoteRelay.url,
|
||||
userToken,
|
||||
instanceId: connectionId,
|
||||
deviceCode: device.device_code,
|
||||
localBaseUrl: baseUrl,
|
||||
machineInfo,
|
||||
relayProtocol: 'mcu-socket.io',
|
||||
})
|
||||
if (!client) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Failed to start relay client' }
|
||||
return
|
||||
}
|
||||
if (!await client.waitForConnected(8000)) {
|
||||
stopOutboundRelayClient(connectionId)
|
||||
ctx.status = 502
|
||||
ctx.body = { error: 'Failed to connect remote relay' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
device: withMcuDeviceStatus(device),
|
||||
devices: listMcuDevicesWithStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectMcuDeviceRemoteController(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'invalid mcu device id' }
|
||||
return
|
||||
}
|
||||
|
||||
const device = getMcuDevice(id)
|
||||
if (!device) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'MCU device not found' }
|
||||
return
|
||||
}
|
||||
|
||||
stopOutboundRelayClient(remoteConnectionId(device.device_code))
|
||||
ctx.body = {
|
||||
device: withMcuDeviceStatus(device),
|
||||
devices: listMcuDevicesWithStatus(),
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { getDb, jsonDelete, jsonGetAll, jsonSet } from '../index'
|
||||
import { MCU_DEVICES_TABLE } from './schemas'
|
||||
|
||||
export interface McuDeviceRecord {
|
||||
id: number
|
||||
name: string
|
||||
device_code: string
|
||||
is_official: boolean
|
||||
created_at: number
|
||||
}
|
||||
|
||||
interface StoredMcuDeviceRow {
|
||||
id: number
|
||||
name: string
|
||||
device_code: string
|
||||
is_official: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
function rowToRecord(row: StoredMcuDeviceRow | Record<string, any>): McuDeviceRecord {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
device_code: String(row.device_code || ''),
|
||||
is_official: Number(row.is_official || 0) === 1,
|
||||
created_at: Number(row.created_at || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function listMcuDevices(): McuDeviceRecord[] {
|
||||
const db = getDb()
|
||||
if (!db) {
|
||||
return Object.values(jsonGetAll(MCU_DEVICES_TABLE))
|
||||
.map(rowToRecord)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
const rows = db.prepare(`SELECT * FROM ${MCU_DEVICES_TABLE} ORDER BY created_at DESC, id DESC`).all() as unknown as StoredMcuDeviceRow[]
|
||||
return rows.map(rowToRecord)
|
||||
}
|
||||
|
||||
export function getMcuDevice(id: number): McuDeviceRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) {
|
||||
const row = jsonGetAll(MCU_DEVICES_TABLE)[String(id)]
|
||||
return row ? rowToRecord(row) : null
|
||||
}
|
||||
|
||||
const row = db.prepare(`SELECT * FROM ${MCU_DEVICES_TABLE} WHERE id = ?`).get(id) as unknown as StoredMcuDeviceRow | undefined
|
||||
return row ? rowToRecord(row) : null
|
||||
}
|
||||
|
||||
export function createMcuDevice(input: {
|
||||
name: string
|
||||
deviceCode: string
|
||||
isOfficial: boolean
|
||||
}): McuDeviceRecord {
|
||||
const db = getDb()
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const name = input.name.trim()
|
||||
const deviceCode = input.deviceCode.trim()
|
||||
const isOfficial = input.isOfficial ? 1 : 0
|
||||
|
||||
if (!db) {
|
||||
const existing = Object.values(jsonGetAll(MCU_DEVICES_TABLE))
|
||||
.map(rowToRecord)
|
||||
.find(record => record.device_code === deviceCode)
|
||||
if (existing) throw new Error('mcu_device_exists')
|
||||
const id = Date.now()
|
||||
const row = { id, name, device_code: deviceCode, is_official: isOfficial, created_at: now }
|
||||
jsonSet(MCU_DEVICES_TABLE, String(id), row as any)
|
||||
return rowToRecord(row)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO ${MCU_DEVICES_TABLE} (name, device_code, is_official, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(name, deviceCode, isOfficial, now)
|
||||
|
||||
const id = Number(result.lastInsertRowid)
|
||||
const row = db.prepare(`SELECT * FROM ${MCU_DEVICES_TABLE} WHERE id = ?`).get(id) as unknown as StoredMcuDeviceRow
|
||||
return rowToRecord(row)
|
||||
} catch (error: any) {
|
||||
if (String(error?.message || '').toLowerCase().includes('unique')) {
|
||||
throw new Error('mcu_device_exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMcuDeviceName(id: number, name: string): McuDeviceRecord {
|
||||
const db = getDb()
|
||||
const normalizedName = name.trim().slice(0, 80)
|
||||
|
||||
if (!db) {
|
||||
const rows = jsonGetAll(MCU_DEVICES_TABLE)
|
||||
const row = rows[String(id)]
|
||||
if (!row) throw new Error('mcu_device_not_found')
|
||||
const next = { ...row, name: normalizedName || String(row.device_code || '') }
|
||||
jsonSet(MCU_DEVICES_TABLE, String(id), next as any)
|
||||
return rowToRecord(next)
|
||||
}
|
||||
|
||||
const existing = db.prepare(`SELECT * FROM ${MCU_DEVICES_TABLE} WHERE id = ?`).get(id) as unknown as StoredMcuDeviceRow | undefined
|
||||
if (!existing) throw new Error('mcu_device_not_found')
|
||||
|
||||
db.prepare(`UPDATE ${MCU_DEVICES_TABLE} SET name = ? WHERE id = ?`).run(normalizedName || existing.device_code, id)
|
||||
const row = db.prepare(`SELECT * FROM ${MCU_DEVICES_TABLE} WHERE id = ?`).get(id) as unknown as StoredMcuDeviceRow
|
||||
return rowToRecord(row)
|
||||
}
|
||||
|
||||
export function deleteMcuDevice(id: number): boolean {
|
||||
const db = getDb()
|
||||
if (!db) {
|
||||
const rows = jsonGetAll(MCU_DEVICES_TABLE)
|
||||
if (!rows[String(id)]) return false
|
||||
jsonDelete(MCU_DEVICES_TABLE, String(id))
|
||||
return true
|
||||
}
|
||||
|
||||
const result = db.prepare(`DELETE FROM ${MCU_DEVICES_TABLE} WHERE id = ?`).run(id)
|
||||
return Number(result.changes) > 0
|
||||
}
|
||||
@@ -324,24 +324,6 @@ export const DEVICES_INDEXES = {
|
||||
idx_devices_last_seen: 'CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at)',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCU Devices
|
||||
// ============================================================================
|
||||
|
||||
export const MCU_DEVICES_TABLE = 'mcu_devices'
|
||||
|
||||
export const MCU_DEVICES_SCHEMA: Record<string, string> = {
|
||||
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
name: "TEXT NOT NULL DEFAULT ''",
|
||||
device_code: 'TEXT NOT NULL UNIQUE',
|
||||
is_official: 'INTEGER NOT NULL DEFAULT 0',
|
||||
created_at: `INTEGER NOT NULL DEFAULT (strftime('%s','now'))`,
|
||||
}
|
||||
|
||||
export const MCU_DEVICES_INDEXES = {
|
||||
idx_mcu_devices_created_at: 'CREATE INDEX IF NOT EXISTS idx_mcu_devices_created_at ON mcu_devices(created_at)',
|
||||
}
|
||||
|
||||
export const STT_PROVIDER_SETTINGS_TABLE = 'stt_provider_settings'
|
||||
|
||||
export const STT_PROVIDER_SETTINGS_SCHEMA: Record<string, string> = {
|
||||
@@ -850,11 +832,6 @@ export function initAllHermesTables(): void {
|
||||
indexes: DEVICES_INDEXES,
|
||||
})
|
||||
|
||||
// MCU devices
|
||||
syncTable(MCU_DEVICES_TABLE, MCU_DEVICES_SCHEMA, {
|
||||
indexes: MCU_DEVICES_INDEXES,
|
||||
})
|
||||
|
||||
syncTable(STT_PROVIDER_SETTINGS_TABLE, STT_PROVIDER_SETTINGS_SCHEMA, {
|
||||
indexes: STT_PROVIDER_SETTINGS_INDEXES,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { uploadRoutes } from './upload'
|
||||
import { updateRoutes } from './update'
|
||||
import { authPublicRoutes, authProtectedRoutes } from './auth'
|
||||
import { devicePublicRoutes, deviceRoutes } from './devices'
|
||||
import { mcuDeviceRoutes } from './mcu-devices'
|
||||
import { apiDocsRoutes } from './api-docs'
|
||||
|
||||
// Hermes route modules
|
||||
@@ -68,7 +67,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
|
||||
// --- Protected routes (auth required) ---
|
||||
app.use(authProtectedRoutes.routes())
|
||||
app.use(deviceRoutes.routes())
|
||||
app.use(mcuDeviceRoutes.routes())
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
app.use(sessionRoutes.routes())
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../controllers/mcu-devices'
|
||||
|
||||
export const mcuDeviceRoutes = new Router()
|
||||
|
||||
mcuDeviceRoutes.get('/api/mcu-devices', ctrl.listMcuDevicesController)
|
||||
mcuDeviceRoutes.post('/api/mcu-devices', ctrl.createMcuDeviceController)
|
||||
mcuDeviceRoutes.post('/api/mcu-devices/:id/remote-connect', ctrl.connectMcuDeviceRemoteController)
|
||||
mcuDeviceRoutes.post('/api/mcu-devices/:id/remote-disconnect', ctrl.disconnectMcuDeviceRemoteController)
|
||||
mcuDeviceRoutes.patch('/api/mcu-devices/:id', ctrl.updateMcuDeviceController)
|
||||
mcuDeviceRoutes.delete('/api/mcu-devices/:id', ctrl.deleteMcuDeviceController)
|
||||
@@ -475,21 +475,6 @@ export class GlobalAgentServer {
|
||||
return Array.from(this.clients.keys())
|
||||
}
|
||||
|
||||
hasMcuDeviceCode(deviceCode: string): boolean {
|
||||
const expected = deviceCode.trim()
|
||||
if (!expected) return false
|
||||
for (const socket of this.clients.values()) {
|
||||
const auth = socket.handshake.auth || {}
|
||||
const raw = typeof auth.deviceCode === 'string'
|
||||
? auth.deviceCode
|
||||
: typeof auth.device_code === 'string'
|
||||
? auth.device_code
|
||||
: ''
|
||||
if (raw.trim() === expected) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async httpRequest(request: RelayHttpRequest, options: GlobalAgentRequestOptions = {}): Promise<RelayHttpResponse> {
|
||||
const socket = this.resolveClient(options.clientId)
|
||||
if (!socket) {
|
||||
|
||||
Reference in New Issue
Block a user