feat: add device sharing metadata application
This commit is contained in:
@@ -245,6 +245,12 @@ const terminalSnapshot: TerminalSnapshot = {
|
||||
};
|
||||
|
||||
const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
sharing: {
|
||||
getSettings: vi.fn(async () => ({ deviceId: 'test-device', name: 'Test device', serverUrl: 'http://127.0.0.1:8787' })),
|
||||
saveSettings: vi.fn(), registerDevice: vi.fn(),
|
||||
getCatalog: vi.fn(async () => ({ devices: [], publications: [] })),
|
||||
publish: vi.fn(), revoke: vi.fn()
|
||||
},
|
||||
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
|
||||
getRuntimeNativeClient: vi.fn(async () => null),
|
||||
stopRuntimeNativeClient: vi.fn(async () => undefined),
|
||||
@@ -14322,24 +14328,40 @@ describe("App", () => {
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
fireEvent.click(move)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控',
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享',
|
||||
]))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[1]!)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记',
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享',
|
||||
]))
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes'])
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记'])
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享'])
|
||||
})
|
||||
|
||||
it('opens device sharing from Application Center while keeping the Settings entry available', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '监督者' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const row = screen.getByText('设备共享', { exact: true }).closest('article')!
|
||||
fireEvent.click(within(row).getByRole('button', { name: '打开' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '设备共享' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled()
|
||||
})
|
||||
expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }))
|
||||
expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
|
||||
import { DocumentConversationContext } from './DocumentConversationContext';
|
||||
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
|
||||
import { buildRuntimeHistory } from '../../shared/runtime-history';
|
||||
import { DeviceSharingPage } from './DeviceSharingPage';
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
@@ -542,7 +543,7 @@ type ActiveRun = {
|
||||
};
|
||||
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
|
||||
|
||||
const intentRoutePreloaders: Partial<
|
||||
Record<WorkspaceView, () => Promise<unknown>>
|
||||
@@ -10923,6 +10924,7 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{view === 'device-sharing' && <DeviceSharingPage notify={notify} />}
|
||||
{localInferenceOpen && (
|
||||
<LocalInferencePage
|
||||
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Application Center', () => {
|
||||
fireEvent.drop(card)
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'],
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'],
|
||||
} })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('Application Center', () => {
|
||||
fireEvent.dragStart(notes)
|
||||
fireEvent.drop(inference)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
} })
|
||||
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).not.toHaveBeenCalled()
|
||||
@@ -238,9 +238,9 @@ describe('Application Center', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
|
||||
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||
expect(moveUp.closest('article')).not.toBeNull()
|
||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||
@@ -248,18 +248,18 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
|
||||
rerender(<ApplicationCenter {...handlers} settings={{
|
||||
...pinnedSettings,
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
|
||||
}} />)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记'])
|
||||
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享'])
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
@@ -281,7 +281,7 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -322,12 +322,12 @@ describe('Application Center', () => {
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } })
|
||||
const card = screen.getByRole('article')
|
||||
expect(card).toHaveAttribute('draggable', 'false')
|
||||
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
|
||||
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeEnabled()
|
||||
fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -342,7 +342,7 @@ describe('Application Center', () => {
|
||||
})
|
||||
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(screen.getAllByRole('article')).toHaveLength(4)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('edits all notes preferences through the shared settings form', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
Network,
|
||||
HeartPulse,
|
||||
Library,
|
||||
Settings,
|
||||
@@ -31,6 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||
import './application-center.css'
|
||||
|
||||
export const applicationDefinitions = {
|
||||
'device-sharing': { icon: Network, title: 'sharing.title' },
|
||||
'magic-notes': {
|
||||
icon: Sparkles,
|
||||
title: 'navigation.magicNotes',
|
||||
@@ -56,7 +58,7 @@ export function isApplicationEnabled(
|
||||
settings: ApplicationSettings | undefined,
|
||||
id: BuiltInApplicationId,
|
||||
): boolean {
|
||||
if (id === 'knowledge') return true
|
||||
if (id === 'knowledge' || id === 'device-sharing') return true
|
||||
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
||||
return (
|
||||
settings !== undefined &&
|
||||
@@ -449,7 +451,7 @@ export function ApplicationCenter({
|
||||
<div className="application-center__title">
|
||||
<strong>{t(definition.title)}</strong>
|
||||
<small>
|
||||
{alwaysShown
|
||||
{id === 'device-sharing' ? t('applications.builtin') : alwaysShown
|
||||
? t('applications.alwaysShown')
|
||||
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
||||
</small>
|
||||
@@ -466,7 +468,7 @@ export function ApplicationCenter({
|
||||
>
|
||||
{t('applications.open')}
|
||||
</button>
|
||||
{!alwaysShown && (
|
||||
{id !== 'knowledge' && id !== 'device-sharing' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DeviceSharingPage } from './DeviceSharingPage'
|
||||
import type { DeviceSharingApi, SharingCatalog, SharingPublicationDraft } from '../../shared/device-sharing-contracts'
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
const settings = { deviceId: 'mine', name: 'My desktop', serverUrl: 'http://127.0.0.1:8787' }
|
||||
function setup() {
|
||||
const catalog: SharingCatalog = { devices: [], publications: [] }
|
||||
const sharing = {
|
||||
getSettings: vi.fn(async () => settings), saveSettings: vi.fn(async input => ({ ...settings, ...input })),
|
||||
getCatalog: vi.fn(async () => structuredClone(catalog)),
|
||||
registerDevice: vi.fn(async () => {
|
||||
const device = { id: 'mine', name: 'My desktop', platform: 'win32', appVersion: '1', registeredAt: 'today', updatedAt: 'today' }
|
||||
catalog.devices.push(device); return device
|
||||
}),
|
||||
publish: vi.fn(async (input: SharingPublicationDraft) => {
|
||||
const item = { ...input, id: 'publication', deviceId: 'mine', status: 'published' as const, createdAt: 'today', updatedAt: 'today' }
|
||||
catalog.publications.push(item); return item
|
||||
}),
|
||||
revoke: vi.fn(async () => { catalog.publications[0]!.status = 'revoked'; return catalog.publications[0]! })
|
||||
} satisfies DeviceSharingApi
|
||||
vi.stubGlobal('goodbuddy', { sharing })
|
||||
const notify = vi.fn()
|
||||
render(<DeviceSharingPage notify={notify} />)
|
||||
return { sharing, catalog, notify }
|
||||
}
|
||||
it('registers, publishes independent knowledge flags, revokes and refreshes actual returned rows', async () => {
|
||||
const { sharing, notify } = setup()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
|
||||
expect(screen.getByRole('button', { name: '发布元数据' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '注册本机' }))
|
||||
await waitFor(() => expect(screen.queryByText('暂无注册设备')).not.toBeInTheDocument())
|
||||
fireEvent.change(screen.getByLabelText('条目名称'), { target: { value: 'Shared knowledge' } })
|
||||
fireEvent.change(screen.getByLabelText('类型'), { target: { value: 'knowledge' } })
|
||||
fireEvent.change(screen.getByLabelText('来源模式'), { target: { value: 'server' } })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '搜索' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '下载' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发布元数据' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布元数据' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '撤销发布' })).toBeEnabled())
|
||||
expect(sharing.publish).toHaveBeenCalledWith(expect.objectContaining({ sourceMode: 'server', permissions: { search: true, read: false, download: true } }))
|
||||
const row = screen.getByText('Shared knowledge').closest('article')!
|
||||
expect(within(row).getByText(/搜索: 允许 · 读取: 不允许 · 下载: 允许/)).toBeInTheDocument()
|
||||
fireEvent.click(within(row).getByRole('button', { name: '撤销发布' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认撤销发布' }))
|
||||
await waitFor(() => { expect(screen.getByText('已撤销')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '刷新列表' })).toBeEnabled() })
|
||||
expect(sharing.revoke).toHaveBeenCalledWith('publication')
|
||||
expect(notify).toHaveBeenCalledWith({ tone: 'success', message: '发布条目已撤销' })
|
||||
})
|
||||
it('preserves unsaved input on failure and does not offer revocation for other device IDs', async () => {
|
||||
const { sharing, catalog, notify } = setup()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
|
||||
catalog.publications.push({ name: 'Other', id: 'other', deviceId: 'theirs', description: '', kind: 'capability', sourceMode: 'device', permissions: { search: false, read: false, download: false }, status: 'published', createdAt: 'today', updatedAt: 'today' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }))
|
||||
await screen.findByText('Other')
|
||||
expect(screen.queryByRole('button', { name: '撤销发布' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByLabelText('设备名称')).toBeEnabled())
|
||||
sharing.saveSettings.mockRejectedValueOnce(new Error('Disk full'))
|
||||
fireEvent.change(screen.getByLabelText('设备名称'), { target: { value: 'My edit' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'Error: Disk full' })))
|
||||
expect(screen.getByLabelText('设备名称')).toHaveValue('My edit')
|
||||
expect(screen.getByRole('button', { name: '注册本机' })).toBeDisabled()
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SharingCatalog, SharingPublicationDraft, SharingSettings } from '../../shared/device-sharing-contracts'
|
||||
import { sharingSettingsInputSchema } from '../../shared/device-sharing-contracts'
|
||||
import { EmptyState, PageHeader, PageShell } from './WorkspacePrimitives'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import './device-sharing.css'
|
||||
|
||||
const emptyDraft: SharingPublicationDraft = {
|
||||
name: '', description: '', kind: 'capability', sourceMode: 'device',
|
||||
permissions: { search: false, read: false, download: false }
|
||||
}
|
||||
|
||||
export function DeviceSharingPage({ notify }: { notify: (input: AppNotificationInput) => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [settings, setSettings] = useState<SharingSettings>()
|
||||
const [name, setName] = useState('')
|
||||
const [serverUrl, setServerUrl] = useState('')
|
||||
const [catalog, setCatalog] = useState<SharingCatalog>()
|
||||
const [draft, setDraft] = useState(emptyDraft)
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [reload, setReload] = useState(0)
|
||||
const [revokeId, setRevokeId] = useState<string>()
|
||||
const pending = useRef(false)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.sharing.getSettings().then(async next => {
|
||||
if (!active) return
|
||||
setSettings(next); setName(next.name); setServerUrl(next.serverUrl)
|
||||
const snapshot = await window.goodbuddy.sharing.getCatalog()
|
||||
if (active) { setCatalog(snapshot); setError('') }
|
||||
}).catch((reason: unknown) => { if (active) setError(String(reason)) })
|
||||
.finally(() => { if (active) setBusy(false) })
|
||||
return () => { active = false }
|
||||
}, [reload])
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
try {
|
||||
setCatalog(await window.goodbuddy.sharing.getCatalog())
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
setError(String(reason))
|
||||
}
|
||||
}
|
||||
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
|
||||
if (pending.current) return
|
||||
pending.current = true; setBusy(true)
|
||||
try {
|
||||
await action()
|
||||
if (message) notify({ tone: 'success', message })
|
||||
} catch (reason) {
|
||||
notify({ tone: 'error', message: String(reason), dedupeKey: 'device-sharing-action' })
|
||||
} finally { pending.current = false; setBusy(false) }
|
||||
}
|
||||
const dirty = settings && (name !== settings.name || serverUrl !== settings.serverUrl)
|
||||
const registered = Boolean(settings && catalog?.devices.some(device => device.id === settings.deviceId))
|
||||
const canPublish = registered && !dirty && !error && !busy
|
||||
const validSettings = sharingSettingsInputSchema.safeParse({ name, serverUrl }).success
|
||||
|
||||
return <PageShell variant="standard">
|
||||
<PageHeader headingId="device-sharing-title" title={t('sharing.title')} scope={{ kind: 'global' }} description={t('sharing.boundary')}
|
||||
actions={<button className="secondary-button" disabled={busy} onClick={() => {
|
||||
if (!settings) { setBusy(true); setReload(value => value + 1) }
|
||||
else void run(refresh)
|
||||
}}>{t('sharing.refresh')}</button>} />
|
||||
<div className="device-sharing" aria-busy={busy}>
|
||||
{busy && <p role="status">{t('sharing.working')}</p>}
|
||||
{error && <div role="alert" className="settings-warning">{t('sharing.loadFailed')} {error}</div>}
|
||||
{settings && <section aria-labelledby="sharing-settings">
|
||||
<h2 id="sharing-settings">{t('sharing.thisDevice')}</h2>
|
||||
<p>{t('sharing.deviceId')}: <code>{settings.deviceId}</code></p>
|
||||
<form onSubmit={event => { event.preventDefault(); void run(async () => {
|
||||
const next = await window.goodbuddy.sharing.saveSettings({ name, serverUrl })
|
||||
setSettings(next); setName(next.name); setServerUrl(next.serverUrl); setCatalog(undefined)
|
||||
await refresh()
|
||||
}, t('sharing.saved')) }}>
|
||||
<label className="field"><span>{t('sharing.serverUrl')}</span><input type="url" required value={serverUrl} disabled={busy} onChange={event => setServerUrl(event.target.value)} /></label>
|
||||
<label className="field"><span>{t('sharing.deviceName')}</span><input required maxLength={200} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
|
||||
<div className="device-sharing__actions">
|
||||
<button className="secondary-button" disabled={busy || !dirty || !validSettings}>{t('sharing.save')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy || Boolean(dirty)} onClick={() => void run(async () => {
|
||||
await window.goodbuddy.sharing.registerDevice(); await refresh()
|
||||
}, t('sharing.registered'))}>{t('sharing.register')}</button>
|
||||
</div>
|
||||
{dirty && <p>{t('sharing.saveFirst')}</p>}
|
||||
</form>
|
||||
</section>}
|
||||
<section aria-labelledby="sharing-devices">
|
||||
<h2 id="sharing-devices">{t('sharing.devices')}</h2>
|
||||
{catalog?.devices.length === 0 && <EmptyState title={t('sharing.noDevices')} description={t('sharing.registerFirst')} />}
|
||||
{catalog?.devices.map(device => <article key={device.id}>
|
||||
<h3>{device.name} {device.id === settings?.deviceId && <small>({t('sharing.thisDevice')})</small>}</h3>
|
||||
<p>{device.platform} · {device.appVersion} · <code>{device.id}</code></p>
|
||||
<p>{t('sharing.updatedAt')}: {device.updatedAt}</p>
|
||||
</article>)}
|
||||
</section>
|
||||
<section aria-labelledby="sharing-publish">
|
||||
<h2 id="sharing-publish">{t('sharing.publishMetadata')}</h2>
|
||||
<p>{t('sharing.sourceHelp')}</p>
|
||||
{!registered && <p>{t('sharing.registerFirst')}</p>}
|
||||
<form onSubmit={event => { event.preventDefault(); if (!canPublish) return; void run(async () => {
|
||||
await window.goodbuddy.sharing.publish(draft); setDraft(emptyDraft); await refresh()
|
||||
}, t('sharing.publishedNotice')) }}>
|
||||
<fieldset disabled={busy}>
|
||||
<label className="field"><span>{t('sharing.name')}</span><input required maxLength={200} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} /></label>
|
||||
<label className="field"><span>{t('sharing.description')}</span><textarea maxLength={4000} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} /></label>
|
||||
<label className="field"><span>{t('sharing.kind')}</span><select value={draft.kind} onChange={event => setDraft({ ...draft, kind: event.target.value as SharingPublicationDraft['kind'] })}>
|
||||
<option value="capability">{t('sharing.capability')}</option><option value="knowledge">{t('sharing.knowledge')}</option>
|
||||
</select></label>
|
||||
<label className="field"><span>{t('sharing.sourceMode')}</span><select value={draft.sourceMode} onChange={event => setDraft({ ...draft, sourceMode: event.target.value as SharingPublicationDraft['sourceMode'] })}>
|
||||
<option value="device">{t('sharing.device')}</option><option value="server">{t('sharing.server')}</option>
|
||||
</select></label>
|
||||
{draft.kind === 'knowledge' && <fieldset><legend>{t('sharing.permissions')}</legend>
|
||||
{(['search', 'read', 'download'] as const).map(permission => <label key={permission} className="device-sharing__permission"><input type="checkbox" checked={draft.permissions[permission]} onChange={event => setDraft({ ...draft, permissions: { ...draft.permissions, [permission]: event.target.checked } })} />{t(`sharing.${permission}`)}</label>)}
|
||||
</fieldset>}
|
||||
</fieldset>
|
||||
<p>{t('sharing.target')}: {settings?.serverUrl} · {settings?.name}</p>
|
||||
<button className="primary-button" disabled={!canPublish || !draft.name.trim()}>{t('sharing.publishMetadata')}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section aria-labelledby="sharing-publications">
|
||||
<h2 id="sharing-publications">{t('sharing.publications')}</h2>
|
||||
{catalog?.publications.length === 0 && <EmptyState title={t('sharing.noPublications')} description={t('sharing.publishHelp')} />}
|
||||
{catalog?.publications.map(item => <article key={item.id}>
|
||||
<h3>{item.name} <small>{t(`sharing.${item.status}`)}</small></h3>
|
||||
<p>{item.description}</p>
|
||||
<p>{t(`sharing.${item.kind}`)} · {t('sharing.sourceMode')}: {t(`sharing.${item.sourceMode}`)}</p>
|
||||
<p>{t('sharing.deviceId')}: <code>{item.deviceId}</code> · {t('sharing.updatedAt')}: {item.updatedAt}</p>
|
||||
{item.kind === 'knowledge' && <p>{(['search', 'read', 'download'] as const).map(key => `${t(`sharing.${key}`)}: ${t(item.permissions[key] ? 'sharing.yes' : 'sharing.no')}`).join(' · ')}</p>}
|
||||
{item.deviceId === settings?.deviceId && item.status === 'published' && (revokeId === item.id ? <div>
|
||||
<p>{t('sharing.revokeHelp')}</p><div className="device-sharing__actions">
|
||||
<button className="secondary-button" disabled={busy} onClick={() => setRevokeId(undefined)}>{t('sharing.cancel')}</button>
|
||||
<button className="danger-solid" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => void run(async () => {
|
||||
await window.goodbuddy.sharing.revoke(item.id); setRevokeId(undefined); await refresh()
|
||||
}, t('sharing.revokedNotice'))}>{t('sharing.confirmRevoke')}</button>
|
||||
</div>
|
||||
</div> : <button className="danger-ghost" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => setRevokeId(item.id)}>{t('sharing.revoke')}</button>)}
|
||||
</article>)}
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.device-sharing { display: grid; gap: var(--space-6); overflow-wrap: anywhere; }
|
||||
.device-sharing section { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
|
||||
.device-sharing h2, .device-sharing h3 { font-size: var(--font-section-title); }
|
||||
.device-sharing small, .device-sharing p { color: var(--text-secondary); }
|
||||
.device-sharing form, .device-sharing fieldset { display: grid; gap: var(--space-3); min-width: 0; }
|
||||
.device-sharing fieldset { border: 0; padding: 0; margin: 0; }
|
||||
.device-sharing form > button { justify-self: start; }
|
||||
.device-sharing article { padding-block: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
|
||||
.device-sharing__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.device-sharing__permission { display: flex; align-items: center; gap: var(--space-2); }
|
||||
@@ -2,6 +2,19 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
sharing: {
|
||||
title: 'Device Sharing', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
|
||||
refresh: 'Refresh lists', working: 'Working…', loadFailed: 'Could not load the lists. Previous results may be stale. Check the service URL and refresh.',
|
||||
thisDevice: 'This device', deviceId: 'Device ID', serverUrl: 'Service URL', deviceName: 'Device name', save: 'Save settings', saved: 'Sharing settings saved',
|
||||
register: 'Register this device', registered: 'Device registration saved to the service', saveFirst: 'Save settings before registering or publishing.',
|
||||
devices: 'Registered devices', noDevices: 'No registered devices', registerFirst: 'Register this device to publish metadata.', updatedAt: 'Updated at',
|
||||
publishMetadata: 'Publish metadata', sourceHelp: 'Source mode describes the entry only. Selecting Server does not upload or sync content; knowledge permissions are declarations only.',
|
||||
name: 'Entry name', description: 'Description', kind: 'Kind', capability: 'Capability', knowledge: 'Knowledge', sourceMode: 'Source mode', device: 'Device', server: 'Server',
|
||||
permissions: 'Knowledge permission declarations', search: 'Search', read: 'Read', download: 'Download', target: 'Publish to', publishedNotice: 'Metadata published',
|
||||
publications: 'Publication catalog', noPublications: 'No publications', publishHelp: 'Enter a name, kind and source to publish metadata.',
|
||||
published: 'Published', revoked: 'Revoked', yes: 'Allowed', no: 'Not allowed', revoke: 'Revoke publication', confirmRevoke: 'Confirm revocation', cancel: 'Cancel',
|
||||
revokeHelp: 'The catalog record will remain with a revoked status. This changes metadata only and does not delete local content.', revokedNotice: 'Publication revoked'
|
||||
},
|
||||
runtimeChecklist: {
|
||||
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
|
||||
finished: 'Request completed', failed: 'Request failed',
|
||||
@@ -22,6 +35,7 @@ export const app = {
|
||||
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||
descriptions: {
|
||||
'device-sharing': 'Register devices, publish and revoke capability or knowledge metadata.',
|
||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export const app = {
|
||||
sharing: {
|
||||
title: '设备共享', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
|
||||
refresh: '刷新列表', working: '正在处理…', loadFailed: '列表读取失败,已有列表可能过期。请检查服务地址并刷新。',
|
||||
thisDevice: '本机', deviceId: '设备 ID', serverUrl: '服务地址', deviceName: '设备名称', save: '保存设置', saved: '共享设置已保存',
|
||||
register: '注册本机', registered: '本机注册信息已保存到服务', saveFirst: '请先保存设置,再注册或发布。',
|
||||
devices: '已注册设备', noDevices: '暂无注册设备', registerFirst: '注册本机后即可发布元数据。', updatedAt: '更新时间',
|
||||
publishMetadata: '发布元数据', sourceHelp: '来源模式只描述条目来源。选择“服务器”不会上传或同步内容;知识权限只保存声明。',
|
||||
name: '条目名称', description: '描述', kind: '类型', capability: '能力', knowledge: '知识', sourceMode: '来源模式', device: '设备', server: '服务器',
|
||||
permissions: '知识权限声明', search: '搜索', read: '读取', download: '下载', target: '发布到', publishedNotice: '元数据已发布',
|
||||
publications: '发布目录', noPublications: '暂无发布条目', publishHelp: '填写名称、类型与来源后发布一条元数据。',
|
||||
published: '已发布', revoked: '已撤销', yes: '允许', no: '不允许', revoke: '撤销发布', confirmRevoke: '确认撤销发布', cancel: '取消',
|
||||
revokeHelp: '撤销后保留目录记录并标记为已撤销。此操作只修改元数据,不删除本机内容。', revokedNotice: '发布条目已撤销'
|
||||
},
|
||||
runtimeChecklist: {
|
||||
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
|
||||
finished: '请求已完成', failed: '请求失败',
|
||||
@@ -19,6 +32,7 @@ export const app = {
|
||||
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||
descriptions: {
|
||||
'device-sharing': '注册设备,发布和撤销能力或知识的元数据。',
|
||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user