dsh-im-ops/plugin-src/client/channels/office/index.js
2026-08-20 12:22:30 +08:00

152 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 故障。'));
}