mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
Merge pull request #20 from mrpompy/local/telegram-private-allowlist
feat: connect AI Office jobs to local Harness
This commit is contained in:
commit
7f114e1c36
31 changed files with 2639 additions and 314 deletions
|
|
@ -131,3 +131,15 @@ export function WhatsappLogoGlyph({ size } = {}) {
|
|||
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
|
||||
}));
|
||||
}
|
||||
|
||||
export function OfficeLogoGlyph({ size } = {}) {
|
||||
return h('svg', {
|
||||
...dimensions(size),
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': 'true',
|
||||
'data-im-channel-logo': 'office',
|
||||
},
|
||||
h('path', { fill: 'currentColor', d: 'M4 3.5h10.5a2 2 0 0 1 2 2v13H4v-15Zm2.2 3v1.8h2V6.5h-2Zm4.1 0v1.8h2V6.5h-2Zm-4.1 4v1.8h2v-1.8h-2Zm4.1 0v1.8h2v-1.8h-2ZM8.4 15v3.5h3V15h-3Z' }),
|
||||
h('path', { fill: 'currentColor', d: 'M18.3 8.2h1.5v3h3v1.5h-3v3h-1.5v-3h-3v-1.5h3v-3Z' }));
|
||||
}
|
||||
|
|
|
|||
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 };
|
||||
152
plugin-src/client/channels/office/index.js
Normal file
152
plugin-src/client/channels/office/index.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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 故障。'));
|
||||
}
|
||||
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(3, 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();
|
||||
}
|
||||
|
|
@ -8,6 +8,51 @@ const EN = Object.freeze({
|
|||
'IM机器人设置': 'IM bot settings',
|
||||
'IM 渠道': 'IM channels',
|
||||
'让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
|
||||
'AI Office': 'AI Office',
|
||||
'AI Office 设置': 'AI Office settings',
|
||||
'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
|
||||
'正在读取 AI Office Connector…': 'Loading AI Office Connector…',
|
||||
'本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
|
||||
'尚未配置': 'Not configured',
|
||||
'已连接 Office': 'Connected to Office',
|
||||
'已配置': 'Configured',
|
||||
'等待重连': 'Waiting to reconnect',
|
||||
'凭据缺失': 'Credential missing',
|
||||
'最近心跳': 'Last heartbeat',
|
||||
'最近事件': 'Last event',
|
||||
'重连次数': 'Reconnects',
|
||||
'运行 Job': 'Running Jobs',
|
||||
'完成 Job': 'Completed Jobs',
|
||||
'尚无': 'None yet',
|
||||
'设备连接': 'Device connection',
|
||||
'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
|
||||
'粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
|
||||
'已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
|
||||
'最大并发': 'Max concurrency',
|
||||
'Heartbeat 秒数': 'Heartbeat seconds',
|
||||
'Workspace 映射': 'Workspace mappings',
|
||||
'每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
|
||||
'Instruction Preset 映射': 'Instruction preset mappings',
|
||||
'每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
|
||||
'保存并连接': 'Save and connect',
|
||||
'测试连接': 'Test connection',
|
||||
'测试中…': 'Testing…',
|
||||
'重新连接': 'Reconnect',
|
||||
'移除连接': 'Remove connection',
|
||||
'连接测试通过。': 'Connection test passed.',
|
||||
'配置已保存。': 'Configuration saved.',
|
||||
'协议 Hook 预览': 'Protocol hook preview',
|
||||
'由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
|
||||
'Base URL 无效': 'Invalid Base URL',
|
||||
'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。': 'Configuration is saved and retried while Office hooks are unavailable; HTTP 404 means the protocol endpoint is pending, not a Harness failure.',
|
||||
'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
|
||||
'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
|
||||
'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
|
||||
'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
|
||||
'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
|
||||
'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
|
||||
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
|
||||
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
|
||||
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
|
||||
'微信': 'WeChat',
|
||||
'飞书': 'Feishu',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
DingtalkLogoGlyph,
|
||||
DiscordLogoGlyph,
|
||||
FeishuLogoGlyph,
|
||||
OfficeLogoGlyph,
|
||||
QqLogoGlyph,
|
||||
SlackLogoGlyph,
|
||||
TelegramLogoGlyph,
|
||||
|
|
@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
|
|||
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
|
||||
import { QqSettingsTab } from './channels/qq/index.js';
|
||||
import { installQqStyles } from './channels/qq/styles.js';
|
||||
import { OFFICE_RPC_CHANNEL } from './channels/office/api.js';
|
||||
import { OfficeSettingsTab } from './channels/office/index.js';
|
||||
import { installOfficeStyles } from './channels/office/styles.js';
|
||||
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
|
||||
import { SlackSettingsTab } from './channels/slack/index.js';
|
||||
import { installSlackStyles } from './channels/slack/styles.js';
|
||||
|
|
@ -54,6 +58,7 @@ const CHANNELS = Object.freeze([
|
|||
{ id: 'telegram', label: 'Telegram' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'whatsapp', label: 'WhatsApp' },
|
||||
{ id: 'office', label: 'AI Office' },
|
||||
]);
|
||||
|
||||
function WeixinLogo() {
|
||||
|
|
@ -99,6 +104,11 @@ function WhatsappLogo() {
|
|||
h(WhatsappLogoGlyph));
|
||||
}
|
||||
|
||||
function OfficeLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
|
||||
h(OfficeLogoGlyph));
|
||||
}
|
||||
|
||||
function ChannelLogo({ channel }) {
|
||||
if (channel === 'weixin') return h(WeixinLogo);
|
||||
if (channel === 'feishu') return h(FeishuLogo);
|
||||
|
|
@ -108,7 +118,8 @@ function ChannelLogo({ channel }) {
|
|||
if (channel === 'slack') return h(SlackLogo);
|
||||
if (channel === 'telegram') return h(TelegramLogo);
|
||||
if (channel === 'discord') return h(DiscordLogo);
|
||||
return h(WhatsappLogo);
|
||||
if (channel === 'whatsapp') return h(WhatsappLogo);
|
||||
return h(OfficeLogo);
|
||||
}
|
||||
|
||||
export function IMSettingsTab({
|
||||
|
|
@ -121,6 +132,7 @@ export function IMSettingsTab({
|
|||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState('weixin');
|
||||
|
|
@ -187,7 +199,9 @@ export function IMSettingsTab({
|
|||
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
||||
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -210,6 +224,7 @@ export function apply(ctx) {
|
|||
installTelegramStyles(),
|
||||
installDiscordStyles(),
|
||||
installWhatsappStyles(),
|
||||
installOfficeStyles(),
|
||||
installImStyles(),
|
||||
];
|
||||
return () => {
|
||||
|
|
@ -235,6 +250,8 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const slackRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const officeRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const workspaceDirectoryPicker = Object.freeze({
|
||||
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
|
|
@ -256,6 +273,7 @@ export function apply(ctx) {
|
|||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
}),
|
||||
}, IMSettingsTab));
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const CSS = String.raw`
|
|||
.dim-logoWecom svg { width: 22px; height: 22px; }
|
||||
.dim-logoTelegram { color: white; background: #229ed9; }
|
||||
.dim-logoTelegram svg { width: 21px; height: 21px; }
|
||||
.dim-logoOffice { color: white; background: linear-gradient(145deg, #12213f, #3964fe); }
|
||||
.dim-logoOffice svg { width: 23px; height: 23px; }
|
||||
.dim-logoDiscord { color: white; background: #5865f2; }
|
||||
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
||||
.dim-logoSlack { color: white; background: #4a154b; }
|
||||
|
|
|
|||
10
plugin-src/host/channels/office/index.mjs
Normal file
10
plugin-src/host/channels/office/index.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createProductionController } from './production.mjs';
|
||||
import { installOfficeRpc } from './rpc.mjs';
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config.controller) return installOfficeRpc(ctx, config.controller, config.rpcAuthority);
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const dispose = installOfficeRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close AI Office connector');
|
||||
return dispose;
|
||||
}
|
||||
45
plugin-src/host/channels/office/production.mjs
Normal file
45
plugin-src/host/channels/office/production.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
|
||||
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
|
||||
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
|
||||
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
|
||||
import { harnessOrigin } from '../shared/production.mjs';
|
||||
|
||||
export function officePaths(config = {}) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-office'));
|
||||
return { root, config: resolve(config.configPath ?? join(root, 'config.json')) };
|
||||
}
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
const Store = internals.ConfigStore ?? OfficeConfigStore;
|
||||
const Controller = internals.Controller ?? OfficeController;
|
||||
const Runtime = internals.Runtime ?? OfficeRuntime;
|
||||
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||
const paths = officePaths(config);
|
||||
const configStore = await new Store(paths.config).load();
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
|
||||
const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
|
||||
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
|
||||
baseUrl: harnessBaseUrl,
|
||||
workspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
rpcIdPrefix: 'office',
|
||||
logPrefix: 'dsh-im:office',
|
||||
}));
|
||||
const controller = new Controller({
|
||||
credentials: ctx.credentials,
|
||||
configStore,
|
||||
logger,
|
||||
createRuntime: (options) => new Runtime({
|
||||
...options,
|
||||
createHarness,
|
||||
...(internals.transport ? { transport: internals.transport } : {}),
|
||||
}),
|
||||
});
|
||||
await controller.initialize();
|
||||
return { controller, close: () => controller.close(), paths };
|
||||
}
|
||||
48
plugin-src/host/channels/office/rpc.mjs
Normal file
48
plugin-src/host/channels/office/rpc.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS } from '../../../../src/channels/office/protocol.mjs';
|
||||
|
||||
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
||||
function exact(value, keys) { return record(value) && Object.keys(value).every((key) => keys.includes(key)); }
|
||||
|
||||
function validConfigure(payload) {
|
||||
return exact(payload, [
|
||||
'baseUrl', 'deviceId', 'deviceToken', 'maxConcurrency', 'heartbeatSeconds',
|
||||
'workspaces', 'instructionPresets',
|
||||
]) && typeof payload.baseUrl === 'string' && typeof payload.deviceId === 'string'
|
||||
&& (payload.deviceToken === undefined || typeof payload.deviceToken === 'string')
|
||||
&& record(payload.workspaces) && record(payload.instructionPresets);
|
||||
}
|
||||
|
||||
export function createOfficeRpcHandler(controller) {
|
||||
for (const method of ['status', 'configure', 'reconnect', 'test', 'remove']) {
|
||||
if (typeof controller?.[method] !== 'function') throw new TypeError(`AI Office controller requires ${method}()`);
|
||||
}
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === OFFICE_RPC_ENDPOINTS.status && exact(payload, [])) value = await controller.status();
|
||||
else if (endpoint === OFFICE_RPC_ENDPOINTS.configure && validConfigure(payload)) value = await controller.configure(payload);
|
||||
else if (endpoint === OFFICE_RPC_ENDPOINTS.reconnect && exact(payload, [])) value = await controller.reconnect();
|
||||
else if (endpoint === OFFICE_RPC_ENDPOINTS.test && exact(payload, [])) value = await controller.test();
|
||||
else if (endpoint === OFFICE_RPC_ENDPOINTS.remove && exact(payload, ['confirm']) && payload.confirm === true) value = await controller.remove();
|
||||
else return { ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' } };
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
const code = error?.code === 'invalid-device-token' ? 'invalid-device-token'
|
||||
: error?.code === 'office-hook-unavailable' ? 'office-hook-unavailable' : 'office-operation-failed';
|
||||
const message = code === 'invalid-device-token' ? 'AI Office Device Token 无效。'
|
||||
: code === 'office-hook-unavailable' ? 'AI Office Hook 尚未上线或地址不正确。'
|
||||
: error instanceof TypeError ? error.message : 'AI Office 连接操作失败,请稍后重试。';
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installOfficeRpc(ctx, controller, authority) {
|
||||
return ctx.connection.rpc.handle(
|
||||
OFFICE_RPC_CHANNEL,
|
||||
createOfficeRpcHandler(controller),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ export function pluginPaths(config, channel) {
|
|||
}
|
||||
|
||||
export async function createTokenProductionController(ctx, config, internals, definitions) {
|
||||
const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions;
|
||||
const {
|
||||
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
|
||||
} = definitions;
|
||||
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
||||
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
|
||||
|
||||
|
|
@ -41,6 +43,11 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||
const ResolvedController = internals.Controller ?? Controller;
|
||||
const ResolvedRuntime = internals.Runtime ?? Runtime;
|
||||
const channelRuntimeOptions = typeof runtimeOptions === 'function' ? runtimeOptions(config) : {};
|
||||
if (!channelRuntimeOptions || typeof channelRuntimeOptions !== 'object'
|
||||
|| Array.isArray(channelRuntimeOptions)) {
|
||||
throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
|
||||
}
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
|
||||
|
|
@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
await workspaces.ensure(botId);
|
||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||
return new ResolvedRuntime({
|
||||
...channelRuntimeOptions,
|
||||
config: botConfig,
|
||||
token,
|
||||
harness: workspaceScope.harness,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
|
|||
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
|
||||
import { createTokenProductionController } from '../shared/production.mjs';
|
||||
|
||||
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
|
||||
|
||||
export function normalizeTelegramAllowedUsers(value) {
|
||||
if (value === undefined) return Object.freeze([]);
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError('telegram.allowedUsers must be an array of numeric Telegram User IDs');
|
||||
}
|
||||
const normalized = value.map((entry) => {
|
||||
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
|
||||
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
|
||||
if (!TELEGRAM_USER_ID.test(userId)) {
|
||||
throw new TypeError('telegram.allowedUsers contains an invalid Telegram User ID');
|
||||
}
|
||||
return userId;
|
||||
});
|
||||
return Object.freeze([...new Set(normalized)]);
|
||||
}
|
||||
|
||||
export function createProductionController(ctx, config = {}, internals = {}) {
|
||||
return createTokenProductionController(ctx, config, internals, {
|
||||
channel: 'telegram',
|
||||
|
|
@ -13,5 +31,8 @@ export function createProductionController(ctx, config = {}, internals = {}) {
|
|||
HarnessClient: TelegramHarnessClient,
|
||||
Controller: TelegramController,
|
||||
Runtime: TelegramRuntime,
|
||||
runtimeOptions: (runtimeConfig) => ({
|
||||
allowedPrivateUserIds: normalizeTelegramAllowedUsers(runtimeConfig.allowedUsers),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
||||
import { apply as applyDiscord } from './channels/discord/index.mjs';
|
||||
import { apply as applyOffice } from './channels/office/index.mjs';
|
||||
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
||||
import { apply as applyQq } from './channels/qq/index.mjs';
|
||||
import { apply as applySlack } from './channels/slack/index.mjs';
|
||||
|
|
@ -27,6 +28,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
const startSlack = internals.applySlack ?? applySlack;
|
||||
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
||||
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
||||
const startOffice = internals.applyOffice ?? applyOffice;
|
||||
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
||||
return Object.freeze({
|
||||
name,
|
||||
|
|
@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
||||
await startDiscord(ctx, channelConfig(config, 'discord'));
|
||||
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
|
||||
await startOffice(ctx, channelConfig(config, 'office'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue