mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
152 lines
9.3 KiB
JavaScript
152 lines
9.3 KiB
JavaScript
import * as React from 'react';
|
||
|
||
import { h } from '../../i18n.js';
|
||
import {
|
||
OFFICE_PROTOCOL_VERSION,
|
||
OFFICE_RPC_ENDPOINTS,
|
||
normalizeOfficeStatus,
|
||
officeHookUrls,
|
||
unwrapOfficeRpc,
|
||
} from './api.js';
|
||
|
||
function Button({ children, kind = 'secondary', ...props }) {
|
||
return h('button', { ...props, type: 'button', className: 'ddt-button', 'data-kind': kind }, children);
|
||
}
|
||
|
||
function mapText(value) {
|
||
return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n');
|
||
}
|
||
|
||
function parseMap(value, label) {
|
||
const output = {};
|
||
for (const raw of value.split(/\r?\n/)) {
|
||
const line = raw.trim();
|
||
if (!line) continue;
|
||
const index = line.indexOf('=');
|
||
if (index < 1 || !line.slice(index + 1).trim()) {
|
||
throw new Error(label === 'Workspace 映射'
|
||
? 'Workspace 映射每行必须使用 alias=value'
|
||
: 'Instruction Preset 映射每行必须使用 alias=value');
|
||
}
|
||
output[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function stateLabel(model) {
|
||
if (model.connected) return '已连接 Office';
|
||
if (!model.configured) return '尚未配置';
|
||
if (model.state === 'connecting') return '正在连接';
|
||
if (model.state === 'reconnecting') return '等待重连';
|
||
if (model.state === 'missing-token') return '凭据缺失';
|
||
return '已配置';
|
||
}
|
||
|
||
export function OfficeSettingsTab({ rpcCall, initialStatus }) {
|
||
const [model, setModel] = React.useState(normalizeOfficeStatus(initialStatus));
|
||
const [phase, setPhase] = React.useState(initialStatus === undefined ? 'loading' : 'ready');
|
||
const [busy, setBusy] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [notice, setNotice] = React.useState('');
|
||
const [form, setForm] = React.useState({
|
||
baseUrl: 'https://fission.gridmind.ai', deviceId: 'local-harness', deviceToken: '',
|
||
maxConcurrency: '1', heartbeatSeconds: '30', workspaces: '', instructionPresets: '',
|
||
});
|
||
|
||
const invoke = React.useCallback(async (endpoint, payload = {}) => {
|
||
if (typeof rpcCall !== 'function') throw new Error('AI Office 设置页缺少 RPC 连接');
|
||
return unwrapOfficeRpc(await rpcCall(endpoint, payload));
|
||
}, [rpcCall]);
|
||
|
||
const adopt = React.useCallback((value) => {
|
||
const next = normalizeOfficeStatus(value?.snapshot ?? value);
|
||
setModel(next);
|
||
if (next.config) setForm((current) => ({
|
||
...current,
|
||
baseUrl: next.config.baseUrl,
|
||
deviceId: next.config.deviceId,
|
||
maxConcurrency: String(next.config.maxConcurrency),
|
||
heartbeatSeconds: String(next.config.heartbeatSeconds),
|
||
workspaces: mapText(next.config.workspaces),
|
||
instructionPresets: mapText(next.config.instructionPresets),
|
||
deviceToken: '',
|
||
}));
|
||
return next;
|
||
}, []);
|
||
|
||
const load = React.useCallback(async () => {
|
||
try { adopt(await invoke(OFFICE_RPC_ENDPOINTS.status)); setPhase('ready'); setError(''); }
|
||
catch (caught) { setPhase('error'); setError(caught.message); }
|
||
}, [adopt, invoke]);
|
||
|
||
React.useEffect(() => { void load(); }, [load]);
|
||
|
||
const run = async (name, operation) => {
|
||
setBusy(name); setError(''); setNotice('');
|
||
try { const value = await operation(); adopt(value); setNotice(name === 'test' ? '连接测试通过。' : '配置已保存。'); }
|
||
catch (caught) { setError(caught.message); }
|
||
finally { setBusy(''); }
|
||
};
|
||
|
||
const hooks = React.useMemo(() => {
|
||
try { return officeHookUrls(form.baseUrl); } catch { return {}; }
|
||
}, [form.baseUrl]);
|
||
const health = model.health ?? {};
|
||
|
||
if (phase === 'loading') return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, '正在读取 AI Office Connector…');
|
||
|
||
return h('section', { className: 'dof-page', 'aria-label': 'AI Office 设置' },
|
||
h('div', { className: 'dof-hero' },
|
||
h('div', { className: 'dof-heroCopy' },
|
||
h('h3', null, 'AI Office Connector'),
|
||
h('p', null, '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ', OFFICE_PROTOCOL_VERSION, '。')),
|
||
h('span', { className: 'dof-status', 'data-connected': String(model.connected) },
|
||
h('span', { className: 'dof-dot' }), stateLabel(model))),
|
||
model.configured ? h('div', { className: 'dof-metrics' },
|
||
h('div', { className: 'dof-metric' }, h('span', null, '最近心跳'), h('strong', null, health.lastHeartbeatAt ?? '尚无')),
|
||
h('div', { className: 'dof-metric' }, h('span', null, '最近事件'), h('strong', null, health.lastEventType ?? '尚无')),
|
||
h('div', { className: 'dof-metric' }, h('span', null, '重连次数'), h('strong', null, String(health.reconnects ?? 0))),
|
||
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0))),
|
||
h('div', { className: 'dof-metric' }, h('span', null, '运行 Job'), h('strong', null, String(health.jobs?.running ?? 0))),
|
||
h('div', { className: 'dof-metric' }, h('span', null, '完成 Job'), h('strong', null, String(health.jobs?.completed ?? 0)))) : null,
|
||
h('div', { className: 'dof-card' },
|
||
h('div', { className: 'dof-cardTitle' }, h('h4', null, '设备连接'), h('span', null, 'Token 只写入本机凭据存储')),
|
||
h('div', { className: 'dof-grid' },
|
||
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Office Base URL',
|
||
h('input', { value: form.baseUrl, placeholder: 'https://fission.gridmind.ai', onChange: (event) => setForm({ ...form, baseUrl: event.target.value }) })),
|
||
h('label', { className: 'dof-field' }, 'Device ID',
|
||
h('input', { value: form.deviceId, placeholder: 'local-harness', onChange: (event) => setForm({ ...form, deviceId: event.target.value }) })),
|
||
h('label', { className: 'dof-field' }, 'Device Token',
|
||
h('input', { type: 'password', value: form.deviceToken, placeholder: model.tokenConfigured ? '已安全保存;留空保持不变' : '粘贴 Office 一次性凭据', autoComplete: 'new-password', onChange: (event) => setForm({ ...form, deviceToken: event.target.value }) })),
|
||
h('label', { className: 'dof-field' }, '最大并发',
|
||
h('input', { type: 'number', min: 1, max: 4, value: form.maxConcurrency, onChange: (event) => setForm({ ...form, maxConcurrency: event.target.value }) })),
|
||
h('label', { className: 'dof-field' }, 'Heartbeat 秒数',
|
||
h('input', { type: 'number', min: 10, max: 300, value: form.heartbeatSeconds, onChange: (event) => setForm({ ...form, heartbeatSeconds: event.target.value }) })),
|
||
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Workspace 映射',
|
||
h('textarea', { value: form.workspaces, placeholder: 'office-project=/Users/you/projects/ai-office', onChange: (event) => setForm({ ...form, workspaces: event.target.value }) }),
|
||
h('small', null, '每行 alias=/本机/绝对路径;Office 只能看到 alias。')),
|
||
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Instruction Preset 映射',
|
||
h('textarea', { value: form.instructionPresets, placeholder: 'action-items=转换为负责人、截止和验收明确的工单', onChange: (event) => setForm({ ...form, instructionPresets: event.target.value }) }),
|
||
h('small', null, '每行 alias=指令;新增 preset 不需要改 Office 代码。'))),
|
||
error ? h('p', { className: 'dof-error', role: 'alert' }, error) : null,
|
||
notice ? h('p', { className: 'dof-notice', role: 'status' }, notice) : null,
|
||
health.error?.message ? h('p', { className: 'dof-error' }, health.error.message) : null,
|
||
h('div', { className: 'dof-actions' },
|
||
h(Button, { kind: 'primary', disabled: Boolean(busy), onClick: () => void run('save', () => invoke(OFFICE_RPC_ENDPOINTS.configure, {
|
||
baseUrl: form.baseUrl,
|
||
deviceId: form.deviceId,
|
||
...(form.deviceToken ? { deviceToken: form.deviceToken } : {}),
|
||
maxConcurrency: Number(form.maxConcurrency),
|
||
heartbeatSeconds: Number(form.heartbeatSeconds),
|
||
workspaces: parseMap(form.workspaces, 'Workspace 映射'),
|
||
instructionPresets: parseMap(form.instructionPresets, 'Instruction Preset 映射'),
|
||
})) }, busy === 'save' ? '保存中…' : '保存并连接'),
|
||
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('test', () => invoke(OFFICE_RPC_ENDPOINTS.test)) }, busy === 'test' ? '测试中…' : '测试连接'),
|
||
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('reconnect', () => invoke(OFFICE_RPC_ENDPOINTS.reconnect)) }, '重新连接'),
|
||
h(Button, { kind: 'danger', disabled: !model.configured || Boolean(busy), onClick: () => void run('remove', () => invoke(OFFICE_RPC_ENDPOINTS.remove, { confirm: true })) }, '移除连接'))),
|
||
h('div', { className: 'dof-card' },
|
||
h('div', { className: 'dof-cardTitle' }, h('h4', null, '协议 Hook 预览'), h('span', null, '由 Base URL 自动派生,不单独填写')),
|
||
h('div', { className: 'dof-hooks' },
|
||
[['SSE', hooks.stream], ['Heartbeat', hooks.heartbeat], ['Job', hooks.job], ['Result', hooks.result]].map(([label, url]) => h('div', { className: 'dof-hook', key: label }, h('strong', null, label), h('code', null, url ?? 'Base URL 无效'))))),
|
||
h('p', { className: 'dof-notice' }, 'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。'));
|
||
}
|