mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 00:03:26 +08:00
feat: secure Telegram access and connect AI Office
This commit is contained in:
parent
e3ae772106
commit
b0606159e0
30 changed files with 2045 additions and 302 deletions
44
plugin-src/client/channels/office/api.js
Normal file
44
plugin-src/client/channels/office/api.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
OFFICE_PROTOCOL_VERSION,
|
||||
OFFICE_RPC_CHANNEL,
|
||||
OFFICE_RPC_ENDPOINTS,
|
||||
officeHookUrls,
|
||||
} from '../../../../src/channels/office/protocol.mjs';
|
||||
|
||||
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
||||
|
||||
export function unwrapOfficeRpc(result) {
|
||||
if (!record(result) || typeof result.ok !== 'boolean') throw new Error('AI Office 服务返回了无法识别的响应');
|
||||
if (!result.ok) {
|
||||
const error = new Error(typeof result.error?.message === 'string' ? result.error.message : 'AI Office 操作失败');
|
||||
error.code = typeof result.error?.code === 'string' ? result.error.code : 'office-rpc-error';
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function normalizeOfficeStatus(value) {
|
||||
if (!record(value) || value.configured !== true) {
|
||||
return { configured: false, connected: false, state: 'unconfigured', config: null, health: null };
|
||||
}
|
||||
const config = record(value.config) ? value.config : {};
|
||||
return {
|
||||
configured: true,
|
||||
connected: value.connected === true,
|
||||
state: typeof value.state === 'string' ? value.state : 'idle',
|
||||
tokenConfigured: value.tokenConfigured === true,
|
||||
config: {
|
||||
protocolVersion: config.protocolVersion ?? OFFICE_PROTOCOL_VERSION,
|
||||
baseUrl: typeof config.baseUrl === 'string' ? config.baseUrl : '',
|
||||
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
|
||||
maxConcurrency: Number(config.maxConcurrency ?? 1),
|
||||
heartbeatSeconds: Number(config.heartbeatSeconds ?? 30),
|
||||
workspaces: record(config.workspaces) ? config.workspaces : {},
|
||||
instructionPresets: record(config.instructionPresets) ? config.instructionPresets : {},
|
||||
hooks: record(config.hooks) ? config.hooks : {},
|
||||
},
|
||||
health: record(value.health) ? value.health : null,
|
||||
};
|
||||
}
|
||||
|
||||
export { OFFICE_PROTOCOL_VERSION, OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS, officeHookUrls };
|
||||
150
plugin-src/client/channels/office/index.js
Normal file
150
plugin-src/client/channels/office/index.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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)))) : 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 故障。'));
|
||||
}
|
||||
50
plugin-src/client/channels/office/styles.js
Normal file
50
plugin-src/client/channels/office/styles.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export const OFFICE_STYLE_ID = 'xmanrui-dsh-im-office-settings';
|
||||
|
||||
const CSS = `
|
||||
.dof-page { --dof-accent: var(--dsw-alias-brand-primary, #3964fe); }
|
||||
.dof-hero { position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; margin-bottom: 12px; padding: 18px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 16px; background: linear-gradient(135deg, color-mix(in srgb, var(--dof-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)), var(--dsw-alias-bg-layer-1, #fff) 62%); }
|
||||
.dof-hero::after { content: ""; position: absolute; width: 150px; height: 150px; right: -75px; top: -90px; border: 24px solid color-mix(in srgb, var(--dof-accent) 12%, transparent); border-radius: 50%; pointer-events: none; }
|
||||
.dof-heroCopy { min-width: 0; }
|
||||
.dof-heroCopy h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; }
|
||||
.dof-heroCopy p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.6; }
|
||||
.dof-status { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 999px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
|
||||
.dof-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-warn-primary, #d97706); }
|
||||
.dof-status[data-connected="true"] .dof-dot { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
|
||||
.dof-card { margin-top: 10px; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
||||
.dof-cardTitle { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 12px; }
|
||||
.dof-cardTitle h4 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; }
|
||||
.dof-cardTitle span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
|
||||
.dof-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.dof-field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
|
||||
.dof-field[data-wide="true"] { grid-column: 1 / -1; }
|
||||
.dof-field input, .dof-field textarea { box-sizing: border-box; width: 100%; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1f2329); font: inherit; font-size: 13px; line-height: 1.5; outline: none; }
|
||||
.dof-field input { height: 38px; padding: 0 11px; }
|
||||
.dof-field textarea { min-height: 86px; resize: vertical; padding: 9px 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.dof-field input:focus, .dof-field textarea:focus { border-color: var(--dof-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dof-accent) 12%, transparent); }
|
||||
.dof-field small { color: var(--dsw-alias-label-tertiary, #8f959e); line-height: 1.45; }
|
||||
.dof-hooks { display: grid; gap: 7px; }
|
||||
.dof-hook { min-width: 0; display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 10px; align-items: center; padding: 8px 10px; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-hook strong { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; }
|
||||
.dof-hook code { overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dof-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.dof-actions .ddt-button[data-kind="primary"] { color: #fff; border-color: var(--dof-accent); background: var(--dof-accent); }
|
||||
.dof-error, .dof-notice { margin: 10px 0 0; padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.5; }
|
||||
.dof-error { color: var(--dsw-alias-state-error-primary, #d54941); background: var(--dsw-alias-state-error-secondary, #fff0ef); }
|
||||
.dof-notice { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
|
||||
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@container (max-width: 680px) { .dof-grid { grid-template-columns: minmax(0, 1fr); } .dof-field[data-wide="true"] { grid-column: auto; } .dof-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (prefers-reduced-motion: reduce) { .dof-page * { transition: none !important; } }
|
||||
`;
|
||||
|
||||
export function installOfficeStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
if (document.querySelector(`style[data-plugin-css="${OFFICE_STYLE_ID}"]`)) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.pluginCss = OFFICE_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.append(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue