@dsh-overdrive/gateway 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/dingtalk.d.ts +21 -0
- package/dist/adapters/dingtalk.js +85 -12
- package/dist/adapters/dingtalk.js.map +1 -1
- package/dist/adapters/feishu.d.ts +9 -0
- package/dist/adapters/feishu.js +65 -11
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/telegram.d.ts +7 -1
- package/dist/adapters/telegram.js +17 -4
- package/dist/adapters/telegram.js.map +1 -1
- package/dist/adapters/wecom.d.ts +2 -0
- package/dist/adapters/wecom.js +12 -9
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/adapters/whatsapp.d.ts +1 -1
- package/dist/adapters/whatsapp.js +9 -12
- package/dist/adapters/whatsapp.js.map +1 -1
- package/dist/asr.d.ts +19 -0
- package/dist/asr.js +63 -0
- package/dist/asr.js.map +1 -0
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +7 -0
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/pending-buttons.d.ts +19 -0
- package/dist/pending-buttons.js +40 -0
- package/dist/pending-buttons.js.map +1 -0
- package/dist/status.d.ts +3 -4
- package/dist/status.js +4 -5
- package/dist/status.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/dingtalk.ts +86 -11
- package/src/adapters/feishu.ts +64 -10
- package/src/adapters/telegram.ts +22 -5
- package/src/adapters/wecom.ts +11 -9
- package/src/adapters/whatsapp.ts +9 -12
- package/src/asr.ts +83 -0
- package/src/commands.ts +8 -1
- package/src/config.ts +6 -0
- package/src/index.ts +35 -1
- package/src/pending-buttons.ts +45 -0
- package/src/status.ts +4 -5
- package/test/adapters.dingtalk.test.ts +34 -1
- package/test/adapters.feishu.test.ts +37 -1
- package/test/asr.test.ts +77 -0
- package/test/commands.test.ts +6 -0
- package/test/pending-buttons.test.ts +61 -0
- package/web/console.html +151 -0
package/test/asr.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createTranscriber, extensionForMime } from '../src/asr.js';
|
|
3
|
+
|
|
4
|
+
describe('extensionForMime', () => {
|
|
5
|
+
it('maps known audio mimes', () => {
|
|
6
|
+
expect(extensionForMime('audio/ogg')).toBe('ogg');
|
|
7
|
+
expect(extensionForMime('audio/mpeg')).toBe('mp3');
|
|
8
|
+
expect(extensionForMime('audio/mp4')).toBe('m4a');
|
|
9
|
+
expect(extensionForMime('audio/wav')).toBe('wav');
|
|
10
|
+
expect(extensionForMime('audio/webm')).toBe('webm');
|
|
11
|
+
});
|
|
12
|
+
it('ignores parameters and case', () => {
|
|
13
|
+
expect(extensionForMime('audio/OGG; codecs=opus')).toBe('ogg');
|
|
14
|
+
});
|
|
15
|
+
it('falls back for unknown or missing mime', () => {
|
|
16
|
+
expect(extensionForMime('application/x-foo')).toBe('oga');
|
|
17
|
+
expect(extensionForMime(undefined)).toBe('oga');
|
|
18
|
+
expect(extensionForMime('')).toBe('oga');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('createTranscriber.transcribe', () => {
|
|
23
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
24
|
+
|
|
25
|
+
it('未配置 API key 时禁用且不发起请求', async () => {
|
|
26
|
+
const fetchMock = vi.fn()
|
|
27
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
28
|
+
const asr = createTranscriber({})
|
|
29
|
+
expect(asr.enabled).toBe(false)
|
|
30
|
+
expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
|
|
31
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('下载音频并调用 OpenAI 兼容转写端点,返回文本', async () => {
|
|
35
|
+
const fetchMock = vi.fn()
|
|
36
|
+
.mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['fake-audio']) })
|
|
37
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ text: ' 你好世界 ' }) })
|
|
38
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
39
|
+
const asr = createTranscriber({ apiKey: 'sk-test', baseUrl: 'https://api.siliconflow.cn/v1', model: 'whisper-1' })
|
|
40
|
+
expect(asr.enabled).toBe(true)
|
|
41
|
+
const text = await asr.transcribe({ url: 'https://x/v.ogg', mime: 'audio/ogg' })
|
|
42
|
+
expect(text).toBe('你好世界')
|
|
43
|
+
|
|
44
|
+
// 第一次 fetch:下载音频;第二次:转写端点
|
|
45
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://x/v.ogg')
|
|
46
|
+
const [url, init] = fetchMock.mock.calls[1]
|
|
47
|
+
expect(String(url)).toBe('https://api.siliconflow.cn/v1/audio/transcriptions')
|
|
48
|
+
expect(init.method).toBe('POST')
|
|
49
|
+
expect(init.headers.authorization).toBe('Bearer sk-test')
|
|
50
|
+
expect(init.body).toBeInstanceOf(FormData)
|
|
51
|
+
const filename = (init.body as FormData).get('file') as File
|
|
52
|
+
expect(filename.name).toBe('voice.ogg')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('下载失败返回 null(不抛错)', async () => {
|
|
56
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 403 }))
|
|
57
|
+
const asr = createTranscriber({ apiKey: 'sk-test' })
|
|
58
|
+
expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('转写端点失败返回 null', async () => {
|
|
62
|
+
const fetchMock = vi.fn()
|
|
63
|
+
.mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['x']) })
|
|
64
|
+
.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'server error' })
|
|
65
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
66
|
+
const asr = createTranscriber({ apiKey: 'sk-test' })
|
|
67
|
+
expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('无 url 返回 null', async () => {
|
|
71
|
+
const fetchMock = vi.fn()
|
|
72
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
73
|
+
const asr = createTranscriber({ apiKey: 'sk-test' })
|
|
74
|
+
expect(await asr.transcribe({ mime: 'audio/ogg' })).toBeNull()
|
|
75
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
})
|
package/test/commands.test.ts
CHANGED
|
@@ -10,9 +10,15 @@ describe('parseCommand', () => {
|
|
|
10
10
|
expect(parseCommand('/task 调研竞品')).toEqual({ kind: 'task', prompt: '调研竞品' });
|
|
11
11
|
expect(parseCommand('/cron 0 8 * * * 每日汇报')).toEqual({ kind: 'cron', schedule: '0 8 * * *', prompt: '每日汇报' });
|
|
12
12
|
});
|
|
13
|
+
it('识别 /crons 与 /cronrm', () => {
|
|
14
|
+
expect(parseCommand('/crons')).toEqual({ kind: 'crons' });
|
|
15
|
+
expect(parseCommand('/cronrm cron-1')).toEqual({ kind: 'cronrm', taskId: 'cron-1' });
|
|
16
|
+
expect(parseCommand('/cronrm cron-123-abc')).toEqual({ kind: 'cronrm', taskId: 'cron-123-abc' });
|
|
17
|
+
});
|
|
13
18
|
it('非命令返回 null', () => {
|
|
14
19
|
expect(parseCommand('hello')).toBeNull();
|
|
15
20
|
expect(parseCommand('/unknown')).toBeNull();
|
|
16
21
|
expect(parseCommand('/task')).toBeNull(); // 缺参数
|
|
22
|
+
expect(parseCommand('/cronrm')).toBeNull(); // 缺任务 id
|
|
17
23
|
});
|
|
18
24
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PendingButtons, PENDING_BUTTONS_TTL_MS } from '../src/pending-buttons.js';
|
|
3
|
+
|
|
4
|
+
describe('PendingButtons', () => {
|
|
5
|
+
it('数字回复命中并消费', () => {
|
|
6
|
+
const pb = new PendingButtons();
|
|
7
|
+
pb.set('chat-1', [
|
|
8
|
+
{ id: 'approve:1', label: '同意' },
|
|
9
|
+
{ id: 'reject:1', label: '拒绝' },
|
|
10
|
+
]);
|
|
11
|
+
expect(pb.match('chat-1', '2')).toEqual({ id: 'reject:1', label: '拒绝' });
|
|
12
|
+
expect(pb.match('chat-1', '1')).toBeUndefined(); // 已消费
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('非数字 / 越界不消费,按钮仍有效', () => {
|
|
16
|
+
const pb = new PendingButtons();
|
|
17
|
+
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
18
|
+
expect(pb.match('chat-1', 'hello')).toBeUndefined();
|
|
19
|
+
expect(pb.match('chat-1', '5')).toBeUndefined();
|
|
20
|
+
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('过期后自动失效(不吞后续数字消息)', () => {
|
|
24
|
+
vi.useFakeTimers();
|
|
25
|
+
try {
|
|
26
|
+
const pb = new PendingButtons(1000);
|
|
27
|
+
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
28
|
+
vi.advanceTimersByTime(1001);
|
|
29
|
+
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
30
|
+
} finally {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('TTL 内不失效', () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
try {
|
|
38
|
+
const pb = new PendingButtons(PENDING_BUTTONS_TTL_MS);
|
|
39
|
+
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
40
|
+
vi.advanceTimersByTime(PENDING_BUTTONS_TTL_MS - 1);
|
|
41
|
+
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
42
|
+
} finally {
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('consume 删除 pending(原生按钮点击)', () => {
|
|
48
|
+
const pb = new PendingButtons();
|
|
49
|
+
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
50
|
+
pb.consume('chat-1');
|
|
51
|
+
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('不同 chat 互不影响', () => {
|
|
55
|
+
const pb = new PendingButtons();
|
|
56
|
+
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
57
|
+
pb.set('chat-2', [{ id: 'b', label: '拒绝' }]);
|
|
58
|
+
expect(pb.match('chat-2', '1')).toEqual({ id: 'b', label: '拒绝' });
|
|
59
|
+
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
60
|
+
});
|
|
61
|
+
});
|
package/web/console.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>dsh-overdrive — Setup</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; max-width: 720px; margin: 32px auto; padding: 0 16px; background: #0f1115; color: #e6e6e6; }
|
|
9
|
+
h1 { font-size: 22px; margin-bottom: 4px; }
|
|
10
|
+
.sub { color: #9ca3af; font-size: 13px; margin-bottom: 20px; }
|
|
11
|
+
.card { background: #1a1d24; border: 1px solid #232a38; border-radius: 12px; padding: 16px; margin: 12px 0; }
|
|
12
|
+
.ok { color: #4ade80; } .bad { color: #f87171; } .dim { color: #9ca3af; } .accent { color: #7c9cff; }
|
|
13
|
+
/* progress */
|
|
14
|
+
.bar { height: 8px; border-radius: 4px; background: #232a38; overflow: hidden; margin: 8px 0 4px; }
|
|
15
|
+
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, #4f7cff, #4ade80); border-radius: 4px; transition: width .6s ease; }
|
|
16
|
+
.steps { counter-reset: step; padding: 0; margin: 12px 0 0; list-style: none; }
|
|
17
|
+
.steps li { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid #1f2632; }
|
|
18
|
+
.steps li:last-child { border-bottom: none; }
|
|
19
|
+
.num { flex: none; width: 26px; height: 26px; border-radius: 50%; background: #232a38; color: #9ca3af; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; margin-top: 2px; }
|
|
20
|
+
.steps li.done .num { background: #1f8b4c; color: #fff; }
|
|
21
|
+
.steps li .body { flex: 1; }
|
|
22
|
+
.steps li .title { font-weight: 600; font-size: 14px; }
|
|
23
|
+
.steps li.done .title { color: #4ade80; }
|
|
24
|
+
.steps li .hint { color: #9ca3af; font-size: 12.5px; margin-top: 3px; line-height: 1.5; }
|
|
25
|
+
.steps li .hint code { background: #232a38; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
|
26
|
+
a { color: #7c9cff; }
|
|
27
|
+
.adapters span { margin-right: 10px; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<h1>⚡ dsh-overdrive — Setup / 设置向导</h1>
|
|
32
|
+
<div class="sub">The OpenClaw of DeepSeek Harness · 打开 <a href="/api/status">/api/status</a> 查看原始状态</div>
|
|
33
|
+
|
|
34
|
+
<!-- 向导 -->
|
|
35
|
+
<div class="card">
|
|
36
|
+
<div style="display:flex; justify-content:space-between; align-items:baseline;">
|
|
37
|
+
<strong>Getting started / 开始使用</strong>
|
|
38
|
+
<span class="dim" id="progtext">0 / 4</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="bar"><div id="bar"></div></div>
|
|
41
|
+
|
|
42
|
+
<ol class="steps">
|
|
43
|
+
<li id="st-dsh">
|
|
44
|
+
<div class="num">1</div>
|
|
45
|
+
<div class="body">
|
|
46
|
+
<div class="title">DeepSeek Harness is running / DSH 已启动</div>
|
|
47
|
+
<div class="hint" id="h-dsh">loading…</div>
|
|
48
|
+
</div>
|
|
49
|
+
</li>
|
|
50
|
+
<li id="st-model">
|
|
51
|
+
<div class="num">2</div>
|
|
52
|
+
<div class="body">
|
|
53
|
+
<div class="title">Model is configured / 已配置模型</div>
|
|
54
|
+
<div class="hint">Open <a href="http://localhost:3080/" target="_blank">DSH Web UI (3080)</a> → settings → pick your model provider & API key. The plugin auto-uses your default model. / 打开 DSH Web UI,在设置里选择模型并填入 API key,插件会自动使用默认模型。</div>
|
|
55
|
+
</div>
|
|
56
|
+
</li>
|
|
57
|
+
<li id="st-plat">
|
|
58
|
+
<div class="num">3</div>
|
|
59
|
+
<div class="body">
|
|
60
|
+
<div class="title">Connect a messaging platform / 连接聊天平台</div>
|
|
61
|
+
<div class="hint"><span id="h-plat">loading…</span></div>
|
|
62
|
+
</div>
|
|
63
|
+
</li>
|
|
64
|
+
<li id="st-chat">
|
|
65
|
+
<div class="num">4</div>
|
|
66
|
+
<div class="body">
|
|
67
|
+
<div class="title">Start chatting / 开始聊天</div>
|
|
68
|
+
<div class="hint">Send <code>/help</code> to your bot, then try <code>/trace</code> · <code>/task</code> · <code>/cron</code>. / 给 bot 发 <code>/help</code>,然后试试 <code>/trace</code> · <code>/task</code> · <code>/cron</code>。</div>
|
|
69
|
+
</div>
|
|
70
|
+
</li>
|
|
71
|
+
</ol>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- 状态 -->
|
|
75
|
+
<div class="card">
|
|
76
|
+
<strong>Status / 状态</strong>
|
|
77
|
+
<div style="margin-top:8px"><strong>DSH</strong>:<span id="dsh">loading…</span></div>
|
|
78
|
+
<div style="margin-top:8px"><strong>Adapters / 适配器</strong>:<span class="adapters" id="adapters">loading…</span></div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<script>
|
|
82
|
+
const PLATFORM_HINTS = {
|
|
83
|
+
telegram: 'Message your bot once, then send <code>/help</code>. / 给 bot 发一条消息,然后发 <code>/help</code>。',
|
|
84
|
+
whatsapp: 'First start prints a QR code in the gateway logs — scan it in WhatsApp → Settings → Linked devices. / 首次启动的日志里有二维码,用 WhatsApp「设置 → 已连接设备」扫码。',
|
|
85
|
+
discord: 'Add the bot to a server and message it. / 把 bot 加进服务器后发消息。',
|
|
86
|
+
slack: 'Install the app into a workspace and message it. / 把应用装进工作区后发消息。',
|
|
87
|
+
feishu: 'Publish the app in the Feishu console, add it as a bot, then message it. / 在飞书开放平台发布应用并加为机器人,然后发消息。',
|
|
88
|
+
dingtalk: 'Add the bot to a group or single chat, then message it. / 把机器人加进群或单聊后发消息。',
|
|
89
|
+
wecom: 'Message the enterprise app. / 给企业微信应用发消息。',
|
|
90
|
+
};
|
|
91
|
+
async function refresh() {
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch('/api/status');
|
|
94
|
+
const s = await r.json();
|
|
95
|
+
let done = 0;
|
|
96
|
+
|
|
97
|
+
// 1. DSH
|
|
98
|
+
const dshEl = document.getElementById('dsh');
|
|
99
|
+
const hDsh = document.getElementById('h-dsh');
|
|
100
|
+
const stDsh = document.getElementById('st-dsh');
|
|
101
|
+
if (s.dsh.status === 'ok') {
|
|
102
|
+
dshEl.innerHTML = '<span class="ok">● ok(v' + s.version + ')</span>';
|
|
103
|
+
hDsh.innerHTML = 'Running. / 运行中。';
|
|
104
|
+
stDsh.classList.add('done'); done++;
|
|
105
|
+
} else {
|
|
106
|
+
dshEl.innerHTML = '<span class="bad">● ' + (s.dsh.error ?? 'unknown') + '</span>';
|
|
107
|
+
hDsh.innerHTML = 'DSH is not reachable — check <code>DSH_BASE_URL</code> / <code>DSH_TOKEN</code> and restart. / DSH 不可达,检查配置后重启。';
|
|
108
|
+
stDsh.classList.remove('done');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. model: always actionable (points to DSH Web UI); mark pending until user confirms chat works
|
|
112
|
+
document.getElementById('st-model').classList.remove('done');
|
|
113
|
+
|
|
114
|
+
// 3. platforms
|
|
115
|
+
const hPlat = document.getElementById('h-plat');
|
|
116
|
+
const stPlat = document.getElementById('st-plat');
|
|
117
|
+
const ul = document.getElementById('adapters');
|
|
118
|
+
ul.innerHTML = '';
|
|
119
|
+
let connected = 0;
|
|
120
|
+
const hints = [];
|
|
121
|
+
for (const a of s.adapters) {
|
|
122
|
+
const span = document.createElement('span');
|
|
123
|
+
if (a.connected === true) { span.className = 'ok'; span.textContent = '● ' + a.id; connected++; }
|
|
124
|
+
else if (a.connected === false) { span.className = 'bad'; span.textContent = '○ ' + a.id; hints.push((PLATFORM_HINTS[a.id] ?? 'Check its env vars. / 检查对应环境变量。') + ' <code>' + a.id + '</code>'); }
|
|
125
|
+
else { span.className = 'dim'; span.textContent = '· ' + a.id; }
|
|
126
|
+
ul.appendChild(span);
|
|
127
|
+
}
|
|
128
|
+
if (connected > 0) {
|
|
129
|
+
hPlat.innerHTML = 'Connected: <b>' + connected + '</b>. ' + (hints[0] ?? 'You are all set. / 已就绪。');
|
|
130
|
+
stPlat.classList.add('done'); done++;
|
|
131
|
+
} else {
|
|
132
|
+
hPlat.innerHTML = 'None connected yet. ' + (hints[0] ?? 'Set <code>GATEWAY_ADAPTERS</code> + platform env vars in <code>.env</code> and restart. / 在 .env 里配置 GATEWAY_ADAPTERS 与平台凭据后重启。');
|
|
133
|
+
stPlat.classList.remove('done');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. chat: same as platforms for now
|
|
137
|
+
document.getElementById('st-chat').classList.toggle('done', connected > 0);
|
|
138
|
+
if (connected > 0) done++;
|
|
139
|
+
|
|
140
|
+
const pct = Math.round((done / 4) * 100);
|
|
141
|
+
document.getElementById('bar').style.width = pct + '%';
|
|
142
|
+
document.getElementById('progtext').textContent = done + ' / 4';
|
|
143
|
+
} catch (e) {
|
|
144
|
+
document.getElementById('dsh').innerHTML = '<span class="bad">console unreachable: ' + e + '</span>';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
refresh();
|
|
148
|
+
setInterval(refresh, 5000);
|
|
149
|
+
</script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|