opc-agent 1.1.0 → 1.1.2
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/CHANGELOG.md +51 -51
- package/CONTRIBUTING.md +75 -75
- package/README.md +341 -101
- package/README.zh-CN.md +382 -55
- package/dist/channels/web.js +256 -256
- package/dist/cli.js +1 -32
- package/dist/deploy/hermes.js +22 -22
- package/dist/deploy/openclaw.js +31 -31
- package/dist/i18n/index.js +60 -9
- package/dist/templates/code-reviewer.js +5 -5
- package/dist/templates/customer-service.js +2 -2
- package/dist/templates/data-analyst.js +5 -5
- package/dist/templates/knowledge-base.js +2 -2
- package/dist/templates/sales-assistant.js +4 -4
- package/dist/templates/teacher.js +6 -6
- package/docs/.vitepress/config.ts +103 -92
- package/docs/api/cli.md +48 -48
- package/docs/api/oad-schema.md +64 -64
- package/docs/api/sdk.md +80 -80
- package/docs/guide/concepts.md +51 -51
- package/docs/guide/configuration.md +79 -79
- package/docs/guide/deployment.md +42 -42
- package/docs/guide/getting-started.md +44 -44
- package/docs/guide/templates.md +28 -28
- package/docs/guide/testing.md +84 -84
- package/docs/index.md +27 -27
- package/docs/zh/api/cli.md +54 -0
- package/docs/zh/api/oad-schema.md +87 -3
- package/docs/zh/api/sdk.md +102 -0
- package/docs/zh/guide/concepts.md +104 -28
- package/docs/zh/guide/configuration.md +135 -39
- package/docs/zh/guide/deployment.md +81 -3
- package/docs/zh/guide/getting-started.md +82 -58
- package/docs/zh/guide/templates.md +84 -22
- package/docs/zh/guide/testing.md +88 -18
- package/docs/zh/index.md +27 -27
- package/examples/customer-service-demo/README.md +90 -90
- package/examples/customer-service-demo/oad.yaml +107 -107
- package/package.json +1 -1
- package/src/analytics/index.ts +66 -66
- package/src/channels/discord.ts +192 -192
- package/src/channels/email.ts +177 -177
- package/src/channels/feishu.ts +236 -236
- package/src/channels/index.ts +15 -15
- package/src/channels/slack.ts +160 -160
- package/src/channels/telegram.ts +90 -90
- package/src/channels/voice.ts +106 -106
- package/src/channels/web.ts +596 -596
- package/src/channels/webhook.ts +199 -199
- package/src/channels/websocket.ts +87 -87
- package/src/channels/wechat.ts +149 -149
- package/src/cli.ts +1 -35
- package/src/core/a2a.ts +143 -143
- package/src/core/agent.ts +152 -152
- package/src/core/analytics-engine.ts +186 -186
- package/src/core/auth.ts +57 -57
- package/src/core/cache.ts +141 -141
- package/src/core/compose.ts +77 -77
- package/src/core/config.ts +14 -14
- package/src/core/errors.ts +148 -148
- package/src/core/hitl.ts +138 -138
- package/src/core/knowledge.ts +210 -210
- package/src/core/logger.ts +57 -57
- package/src/core/orchestrator.ts +215 -215
- package/src/core/performance.ts +187 -187
- package/src/core/rate-limiter.ts +128 -128
- package/src/core/room.ts +109 -109
- package/src/core/runtime.ts +152 -152
- package/src/core/sandbox.ts +101 -101
- package/src/core/security.ts +171 -171
- package/src/core/types.ts +68 -68
- package/src/core/versioning.ts +106 -106
- package/src/core/watch.ts +178 -178
- package/src/core/workflow.ts +235 -235
- package/src/deploy/hermes.ts +156 -156
- package/src/deploy/openclaw.ts +200 -200
- package/src/dtv/data.ts +29 -29
- package/src/dtv/trust.ts +43 -43
- package/src/dtv/value.ts +47 -47
- package/src/i18n/index.ts +216 -165
- package/src/index.ts +110 -110
- package/src/marketplace/index.ts +223 -223
- package/src/memory/deepbrain.ts +108 -108
- package/src/memory/index.ts +34 -34
- package/src/plugins/index.ts +208 -208
- package/src/providers/index.ts +183 -183
- package/src/schema/oad.ts +155 -155
- package/src/skills/base.ts +16 -16
- package/src/skills/document.ts +100 -100
- package/src/skills/http.ts +35 -35
- package/src/skills/index.ts +27 -27
- package/src/skills/scheduler.ts +80 -80
- package/src/skills/webhook-trigger.ts +59 -59
- package/src/templates/code-reviewer.ts +34 -34
- package/src/templates/customer-service.ts +80 -80
- package/src/templates/data-analyst.ts +70 -70
- package/src/templates/executive-assistant.ts +71 -71
- package/src/templates/financial-advisor.ts +60 -60
- package/src/templates/knowledge-base.ts +31 -31
- package/src/templates/legal-assistant.ts +71 -71
- package/src/templates/sales-assistant.ts +79 -79
- package/src/templates/teacher.ts +79 -79
- package/src/testing/index.ts +181 -181
- package/src/tools/calculator.ts +73 -73
- package/src/tools/datetime.ts +149 -149
- package/src/tools/json-transform.ts +187 -187
- package/src/tools/mcp.ts +76 -76
- package/src/tools/text-analysis.ts +116 -116
- package/templates/Dockerfile +15 -15
- package/templates/code-reviewer/README.md +27 -27
- package/templates/code-reviewer/oad.yaml +41 -41
- package/templates/customer-service/README.md +22 -22
- package/templates/customer-service/oad.yaml +36 -36
- package/templates/docker-compose.yml +21 -21
- package/templates/knowledge-base/README.md +28 -28
- package/templates/knowledge-base/oad.yaml +38 -38
- package/templates/sales-assistant/README.md +26 -26
- package/templates/sales-assistant/oad.yaml +43 -43
- package/tests/a2a.test.ts +66 -66
- package/tests/agent.test.ts +72 -72
- package/tests/analytics.test.ts +50 -50
- package/tests/channel.test.ts +39 -39
- package/tests/e2e.test.ts +134 -134
- package/tests/errors.test.ts +83 -83
- package/tests/hitl.test.ts +71 -71
- package/tests/i18n.test.ts +41 -41
- package/tests/mcp.test.ts +54 -54
- package/tests/oad.test.ts +68 -68
- package/tests/performance.test.ts +115 -115
- package/tests/plugin.test.ts +74 -74
- package/tests/room.test.ts +106 -106
- package/tests/runtime.test.ts +42 -42
- package/tests/sandbox.test.ts +46 -46
- package/tests/security.test.ts +60 -60
- package/tests/templates.test.ts +77 -77
- package/tests/v070.test.ts +76 -76
- package/tests/versioning.test.ts +75 -75
- package/tests/voice.test.ts +61 -61
- package/tests/webhook.test.ts +29 -29
- package/tests/workflow.test.ts +143 -143
- package/tsconfig.json +19 -19
- package/vitest.config.ts +9 -9
package/src/channels/web.ts
CHANGED
|
@@ -1,596 +1,596 @@
|
|
|
1
|
-
import express, { type Express, type Request, type Response } from 'express';
|
|
2
|
-
import type { Server } from 'http';
|
|
3
|
-
import type { Message } from '../core/types';
|
|
4
|
-
import { BaseChannel } from './index';
|
|
5
|
-
import { KnowledgeBase } from '../core/knowledge';
|
|
6
|
-
import { createProvider, type LLMProvider } from '../providers';
|
|
7
|
-
import { createAuthMiddleware, type AuthConfig } from '../core/auth';
|
|
8
|
-
|
|
9
|
-
const AGENT_TEMPLATES = [
|
|
10
|
-
{ id: 'customer-service', name: 'Customer Service', description: 'Handle support tickets, FAQs, and customer inquiries', icon: '🎧', category: 'Business' },
|
|
11
|
-
{ id: 'code-reviewer', name: 'Code Reviewer', description: 'Review PRs, suggest improvements, check for bugs', icon: '🔍', category: 'Engineering' },
|
|
12
|
-
{ id: 'content-writer', name: 'Content Writer', description: 'Write blogs, social media posts, and marketing copy', icon: '✍️', category: 'Marketing' },
|
|
13
|
-
{ id: 'executive-assistant', name: 'Executive Assistant', description: 'Schedule management, email drafting, meeting prep', icon: '📋', category: 'Business' },
|
|
14
|
-
{ id: 'knowledge-base', name: 'Knowledge Base', description: 'RAG-powered Q&A over your documents', icon: '📚', category: 'Knowledge' },
|
|
15
|
-
{ id: 'project-manager', name: 'Project Manager', description: 'Track tasks, milestones, and team coordination', icon: '📊', category: 'Business' },
|
|
16
|
-
{ id: 'sales-assistant', name: 'Sales Assistant', description: 'Lead qualification, outreach drafting, CRM updates', icon: '💼', category: 'Sales' },
|
|
17
|
-
{ id: 'financial-advisor', name: 'Financial Advisor', description: 'Budget analysis, financial planning, cost optimization', icon: '💰', category: 'Finance' },
|
|
18
|
-
{ id: 'hr-recruiter', name: 'HR Recruiter', description: 'Resume screening, interview scheduling, candidate comms', icon: '👥', category: 'HR' },
|
|
19
|
-
{ id: 'legal-assistant', name: 'Legal Assistant', description: 'Contract review, compliance checks, legal research', icon: '⚖️', category: 'Legal' },
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
const TEMPLATES_HTML = `<!DOCTYPE html>
|
|
23
|
-
<html lang="en">
|
|
24
|
-
<head>
|
|
25
|
-
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
26
|
-
<title>Agent Templates</title>
|
|
27
|
-
<style>
|
|
28
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
29
|
-
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px}
|
|
30
|
-
h1{font-size:28px;margin-bottom:8px;color:#fff}
|
|
31
|
-
.sub{color:#888;margin-bottom:32px;font-size:14px}
|
|
32
|
-
nav{margin-bottom:24px}
|
|
33
|
-
nav a{color:#818cf8;text-decoration:none;margin-right:16px;font-size:14px}
|
|
34
|
-
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
|
|
35
|
-
.card{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:24px;cursor:pointer;transition:all .2s}
|
|
36
|
-
.card:hover{border-color:#818cf8;transform:translateY(-2px)}
|
|
37
|
-
.card .icon{font-size:32px;margin-bottom:12px}
|
|
38
|
-
.card h3{font-size:16px;color:#fff;margin-bottom:8px}
|
|
39
|
-
.card p{font-size:13px;color:#888;line-height:1.5}
|
|
40
|
-
.card .cat{font-size:11px;color:#818cf8;text-transform:uppercase;letter-spacing:1px;margin-top:12px}
|
|
41
|
-
.btn{display:inline-block;background:#2563eb;color:#fff;border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;margin-top:12px}
|
|
42
|
-
.btn:hover{background:#1d4ed8}
|
|
43
|
-
</style>
|
|
44
|
-
</head>
|
|
45
|
-
<body>
|
|
46
|
-
<nav><a href="/">← Chat</a><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
|
|
47
|
-
<h1>🧩 Agent Templates</h1>
|
|
48
|
-
<p class="sub">Create a new agent from a pre-built template in one click.</p>
|
|
49
|
-
<div class="grid" id="grid"></div>
|
|
50
|
-
<script>
|
|
51
|
-
fetch('/api/templates').then(r=>r.json()).then(d=>{
|
|
52
|
-
const g=document.getElementById('grid');
|
|
53
|
-
d.templates.forEach(t=>{
|
|
54
|
-
g.innerHTML+=\`<div class="card"><div class="icon">\${t.icon}</div><h3>\${t.name}</h3><p>\${t.description}</p><div class="cat">\${t.category}</div><button class="btn" onclick="alert('Creating agent from template: '+'\${t.id}'+'\\\\nRun: opc init --template \${t.id}')">Use Template</button></div>\`;
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
</script>
|
|
58
|
-
</body>
|
|
59
|
-
</html>`;
|
|
60
|
-
|
|
61
|
-
const CHAT_HTML = `<!DOCTYPE html>
|
|
62
|
-
<html lang="en">
|
|
63
|
-
<head>
|
|
64
|
-
<meta charset="UTF-8">
|
|
65
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
66
|
-
<title>OPC Agent</title>
|
|
67
|
-
<style>
|
|
68
|
-
:root{--bg:#0a0a0f;--surface:#12121a;--border:#1e1e2e;--text:#e0e0e0;--text-dim:#888;--accent:#818cf8;--accent-hover:#6366f1;--user-bg:#2563eb;--user-hover:#1d4ed8;--error-bg:#7f1d1d;--error-text:#fca5a5;--success:#22c55e;--radius:12px}
|
|
69
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
70
|
-
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
|
|
71
|
-
header{background:var(--surface);padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;backdrop-filter:blur(12px)}
|
|
72
|
-
header .avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#6366f1);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
|
73
|
-
header .info{flex:1;min-width:0}
|
|
74
|
-
header h1{font-size:16px;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
75
|
-
header .status{font-size:12px;color:var(--success);display:flex;align-items:center;gap:4px}
|
|
76
|
-
header .status .dot{width:6px;height:6px;border-radius:50%;background:var(--success);animation:pulse 2s infinite}
|
|
77
|
-
nav.header-nav{display:flex;gap:4px}
|
|
78
|
-
nav.header-nav a{color:var(--text-dim);text-decoration:none;font-size:12px;padding:4px 10px;border-radius:6px;transition:all .2s}
|
|
79
|
-
nav.header-nav a:hover{color:#fff;background:rgba(255,255,255,.06)}
|
|
80
|
-
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
81
|
-
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
82
|
-
@keyframes slideIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
|
|
83
|
-
#messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:12px;scroll-behavior:smooth}
|
|
84
|
-
#messages::-webkit-scrollbar{width:4px}
|
|
85
|
-
#messages::-webkit-scrollbar-track{background:transparent}
|
|
86
|
-
#messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
|
87
|
-
.msg-wrap{display:flex;flex-direction:column;animation:fadeIn .3s ease-out}
|
|
88
|
-
.msg-wrap.user{align-items:flex-end}
|
|
89
|
-
.msg-wrap.assistant{align-items:flex-start}
|
|
90
|
-
.msg{max-width:min(720px,85%);padding:10px 14px;border-radius:var(--radius);line-height:1.7;font-size:14px;word-break:break-word;position:relative;transition:all .2s}
|
|
91
|
-
.msg.user{background:var(--user-bg);color:#fff;border-bottom-right-radius:4px}
|
|
92
|
-
.msg.assistant{background:var(--surface);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px}
|
|
93
|
-
.msg.error{background:var(--error-bg);color:var(--error-text);border:1px solid rgba(239,68,68,.3)}
|
|
94
|
-
.msg pre{background:rgba(0,0,0,.4);padding:12px;border-radius:8px;overflow-x:auto;margin:8px 0;font-size:13px;font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;line-height:1.5}
|
|
95
|
-
.msg code{font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;font-size:13px;background:rgba(0,0,0,.3);padding:1px 5px;border-radius:4px}
|
|
96
|
-
.msg pre code{background:none;padding:0}
|
|
97
|
-
.msg .cursor{display:inline-block;width:2px;height:14px;background:var(--accent);animation:blink .6s infinite;vertical-align:text-bottom;margin-left:2px}
|
|
98
|
-
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
|
|
99
|
-
.typing{display:flex;gap:4px;padding:12px 16px;align-items:center}
|
|
100
|
-
.typing span{width:6px;height:6px;border-radius:50%;background:var(--text-dim);animation:typingDot 1.4s infinite}
|
|
101
|
-
.typing span:nth-child(2){animation-delay:.2s}
|
|
102
|
-
.typing span:nth-child(3){animation-delay:.4s}
|
|
103
|
-
@keyframes typingDot{0%,60%,100%{opacity:.3;transform:scale(.8)}30%{opacity:1;transform:scale(1)}}
|
|
104
|
-
.reactions{display:flex;gap:4px;margin-top:4px}
|
|
105
|
-
.reactions button{background:rgba(255,255,255,.06);border:1px solid transparent;border-radius:16px;padding:2px 8px;font-size:13px;cursor:pointer;transition:all .15s;color:var(--text-dim)}
|
|
106
|
-
.reactions button:hover{background:rgba(255,255,255,.12);border-color:var(--border)}
|
|
107
|
-
.reactions button.active{background:rgba(99,102,241,.2);border-color:var(--accent);color:var(--accent)}
|
|
108
|
-
.msg-time{font-size:11px;color:var(--text-dim);margin-top:2px;opacity:0;transition:opacity .2s}
|
|
109
|
-
.msg-wrap:hover .msg-time{opacity:1}
|
|
110
|
-
.attachment{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.3);padding:8px 12px;border-radius:8px;margin-top:6px;font-size:13px}
|
|
111
|
-
.attachment .icon{font-size:18px}
|
|
112
|
-
#input-area{background:var(--surface);padding:12px 20px 16px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:flex-end;flex-shrink:0}
|
|
113
|
-
#input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;color:#fff;font-size:14px;outline:none;resize:none;max-height:150px;min-height:42px;font-family:inherit;line-height:1.5;transition:border-color .2s}
|
|
114
|
-
#input:focus{border-color:var(--accent)}
|
|
115
|
-
#input::placeholder{color:var(--text-dim)}
|
|
116
|
-
#send{background:var(--user-bg);color:#fff;border:none;border-radius:var(--radius);width:42px;height:42px;font-size:18px;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
117
|
-
#send:hover{background:var(--user-hover);transform:scale(1.05)}
|
|
118
|
-
#send:disabled{background:#334155;cursor:not-allowed;transform:none}
|
|
119
|
-
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-dim);gap:12px;padding:40px;text-align:center}
|
|
120
|
-
.empty-state .logo{font-size:48px;opacity:.6}
|
|
121
|
-
.empty-state h2{color:var(--text);font-size:20px;font-weight:500}
|
|
122
|
-
.empty-state p{font-size:14px;max-width:400px;line-height:1.6}
|
|
123
|
-
@media(max-width:640px){
|
|
124
|
-
header{padding:10px 14px}
|
|
125
|
-
#messages{padding:12px}
|
|
126
|
-
#input-area{padding:10px 14px 14px}
|
|
127
|
-
.msg{max-width:90%;font-size:14px}
|
|
128
|
-
nav.header-nav{display:none}
|
|
129
|
-
}
|
|
130
|
-
</style>
|
|
131
|
-
</head>
|
|
132
|
-
<body>
|
|
133
|
-
<header>
|
|
134
|
-
<div class="avatar" id="avatar">🤖</div>
|
|
135
|
-
<div class="info"><h1 id="title">OPC Agent</h1><div class="status"><span class="dot"></span>Online</div></div>
|
|
136
|
-
<nav class="header-nav"><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
|
|
137
|
-
</header>
|
|
138
|
-
<div id="messages">
|
|
139
|
-
<div class="empty-state" id="empty"><div class="logo">💬</div><h2>Start a conversation</h2><p>Type a message below to chat with your AI agent.</p></div>
|
|
140
|
-
</div>
|
|
141
|
-
<div id="input-area">
|
|
142
|
-
<textarea id="input" rows="1" placeholder="Type a message…" autocomplete="off"></textarea>
|
|
143
|
-
<button id="send" aria-label="Send">↑</button>
|
|
144
|
-
</div>
|
|
145
|
-
<script>
|
|
146
|
-
const msgs=document.getElementById('messages'),input=document.getElementById('input'),btn=document.getElementById('send'),empty=document.getElementById('empty');
|
|
147
|
-
let sessionId=crypto.randomUUID(),sending=false;
|
|
148
|
-
|
|
149
|
-
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
150
|
-
function fmtTime(){return new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
|
|
151
|
-
function renderMd(text){
|
|
152
|
-
let h=esc(text);
|
|
153
|
-
h=h.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g,'<pre><code>$2</code></pre>');
|
|
154
|
-
h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
|
|
155
|
-
h=h.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>');
|
|
156
|
-
h=h.replace(/\\n/g,'<br>');
|
|
157
|
-
return h;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function addMsg(role,text,opts){
|
|
161
|
-
if(empty)empty.remove();
|
|
162
|
-
const wrap=document.createElement('div');
|
|
163
|
-
wrap.className='msg-wrap '+role;
|
|
164
|
-
const d=document.createElement('div');
|
|
165
|
-
d.className='msg '+role;
|
|
166
|
-
if(opts?.html)d.innerHTML=text;else if(role==='assistant'&&text)d.innerHTML=renderMd(text);else d.textContent=text;
|
|
167
|
-
wrap.appendChild(d);
|
|
168
|
-
const time=document.createElement('div');
|
|
169
|
-
time.className='msg-time';
|
|
170
|
-
time.textContent=fmtTime();
|
|
171
|
-
wrap.appendChild(time);
|
|
172
|
-
if(role==='assistant'&&text){
|
|
173
|
-
const rx=document.createElement('div');rx.className='reactions';
|
|
174
|
-
rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
|
|
175
|
-
wrap.appendChild(rx);
|
|
176
|
-
}
|
|
177
|
-
msgs.appendChild(wrap);
|
|
178
|
-
msgs.scrollTop=msgs.scrollHeight;
|
|
179
|
-
return d;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
window.react=function(el){el.classList.toggle('active')};
|
|
183
|
-
|
|
184
|
-
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
|
|
185
|
-
input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,150)+'px'});
|
|
186
|
-
btn.addEventListener('click',send);
|
|
187
|
-
|
|
188
|
-
async function send(){
|
|
189
|
-
const text=input.value.trim();
|
|
190
|
-
if(!text||sending)return;
|
|
191
|
-
sending=true;btn.disabled=true;
|
|
192
|
-
input.value='';input.style.height='auto';
|
|
193
|
-
addMsg('user',text);
|
|
194
|
-
const wrap=document.createElement('div');wrap.className='msg-wrap assistant';
|
|
195
|
-
const d=document.createElement('div');d.className='msg assistant';
|
|
196
|
-
d.innerHTML='<div class="typing"><span></span><span></span><span></span></div>';
|
|
197
|
-
wrap.appendChild(d);
|
|
198
|
-
const time=document.createElement('div');time.className='msg-time';time.textContent=fmtTime();
|
|
199
|
-
wrap.appendChild(time);
|
|
200
|
-
msgs.appendChild(wrap);msgs.scrollTop=msgs.scrollHeight;
|
|
201
|
-
try{
|
|
202
|
-
const res=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,sessionId})});
|
|
203
|
-
if(!res.ok)throw new Error('HTTP '+res.status);
|
|
204
|
-
const reader=res.body.getReader(),dec=new TextDecoder();
|
|
205
|
-
let full='';
|
|
206
|
-
while(true){
|
|
207
|
-
const{done,value}=await reader.read();
|
|
208
|
-
if(done)break;
|
|
209
|
-
const chunk=dec.decode(value,{stream:true});
|
|
210
|
-
const lines=chunk.split('\\n');
|
|
211
|
-
for(const line of lines){
|
|
212
|
-
if(!line.startsWith('data: '))continue;
|
|
213
|
-
const dd=line.slice(6);if(dd==='[DONE]')continue;
|
|
214
|
-
try{const j=JSON.parse(dd);if(j.content)full+=j.content;if(j.error)full='Error: '+j.error;}catch{}
|
|
215
|
-
}
|
|
216
|
-
d.innerHTML=renderMd(full)+'<span class="cursor"></span>';
|
|
217
|
-
msgs.scrollTop=msgs.scrollHeight;
|
|
218
|
-
}
|
|
219
|
-
if(!full){d.textContent='(empty response)';}else{d.innerHTML=renderMd(full);}
|
|
220
|
-
const rx=document.createElement('div');rx.className='reactions';
|
|
221
|
-
rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
|
|
222
|
-
wrap.appendChild(rx);
|
|
223
|
-
}catch(e){d.className='msg error';d.textContent='Error: '+e.message;}
|
|
224
|
-
sending=false;btn.disabled=false;input.focus();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
fetch('/api/info').then(r=>r.json()).then(d=>{if(d.name)document.getElementById('title').textContent=d.name}).catch(()=>{});
|
|
228
|
-
</script>
|
|
229
|
-
</body>
|
|
230
|
-
</html>`;
|
|
231
|
-
|
|
232
|
-
const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
233
|
-
<html lang="en">
|
|
234
|
-
<head>
|
|
235
|
-
<meta charset="UTF-8">
|
|
236
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
237
|
-
<title>OPC Dashboard</title>
|
|
238
|
-
<style>
|
|
239
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
240
|
-
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px}
|
|
241
|
-
h1{font-size:24px;margin-bottom:24px;color:#fff}
|
|
242
|
-
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:32px}
|
|
243
|
-
.card{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:20px}
|
|
244
|
-
.card .label{font-size:12px;color:#888;text-transform:uppercase;letter-spacing:1px}
|
|
245
|
-
.card .value{font-size:32px;font-weight:700;color:#818cf8;margin-top:4px}
|
|
246
|
-
.card .sub{font-size:12px;color:#555;margin-top:4px}
|
|
247
|
-
nav{margin-bottom:24px}
|
|
248
|
-
nav a{color:#818cf8;text-decoration:none;margin-right:16px;font-size:14px}
|
|
249
|
-
nav a:hover{text-decoration:underline}
|
|
250
|
-
.chart{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:20px;margin-bottom:16px}
|
|
251
|
-
.chart h3{font-size:14px;color:#888;margin-bottom:12px}
|
|
252
|
-
</style>
|
|
253
|
-
</head>
|
|
254
|
-
<body>
|
|
255
|
-
<nav><a href="/">← Chat</a><a href="/dashboard">Dashboard</a></nav>
|
|
256
|
-
<h1>📊 Agent Dashboard</h1>
|
|
257
|
-
<div class="grid">
|
|
258
|
-
<div class="card"><div class="label">Sessions</div><div class="value" id="sessions">0</div></div>
|
|
259
|
-
<div class="card"><div class="label">Messages</div><div class="value" id="messages">0</div></div>
|
|
260
|
-
<div class="card"><div class="label">Avg Response</div><div class="value" id="avgMs">0ms</div></div>
|
|
261
|
-
<div class="card"><div class="label">Token Usage</div><div class="value" id="tokens">0</div></div>
|
|
262
|
-
<div class="card"><div class="label">Uptime</div><div class="value" id="uptime">0m</div></div>
|
|
263
|
-
<div class="card"><div class="label">Knowledge Files</div><div class="value" id="kb">0</div></div>
|
|
264
|
-
</div>
|
|
265
|
-
<div class="chart"><h3>Messages Over Time</h3><svg id="chart" width="100%" height="120" viewBox="0 0 600 120"></svg></div>
|
|
266
|
-
<script>
|
|
267
|
-
async function refresh(){
|
|
268
|
-
try{
|
|
269
|
-
const r=await fetch('/api/dashboard');const d=await r.json();
|
|
270
|
-
document.getElementById('sessions').textContent=d.sessions;
|
|
271
|
-
document.getElementById('messages').textContent=d.messages;
|
|
272
|
-
document.getElementById('avgMs').textContent=d.messages>0?Math.round(d.totalResponseMs/d.messages)+'ms':'0ms';
|
|
273
|
-
document.getElementById('tokens').textContent=d.tokenUsage.toLocaleString();
|
|
274
|
-
document.getElementById('kb').textContent=d.knowledgeFiles;
|
|
275
|
-
const mins=Math.round((Date.now()-d.startedAt)/60000);
|
|
276
|
-
document.getElementById('uptime').textContent=mins<60?mins+'m':Math.round(mins/60)+'h '+mins%60+'m';
|
|
277
|
-
}catch{}
|
|
278
|
-
}
|
|
279
|
-
refresh();setInterval(refresh,5000);
|
|
280
|
-
</script>
|
|
281
|
-
</body>
|
|
282
|
-
</html>`;
|
|
283
|
-
|
|
284
|
-
export class WebChannel extends BaseChannel {
|
|
285
|
-
readonly type = 'web';
|
|
286
|
-
private app: Express;
|
|
287
|
-
private server: Server | null = null;
|
|
288
|
-
private port: number;
|
|
289
|
-
private streamHandler: ((msg: Message, res: Response) => Promise<void>) | null = null;
|
|
290
|
-
private agentName: string = 'OPC Agent';
|
|
291
|
-
private currentProvider: string = 'openai';
|
|
292
|
-
private stats = { sessions: 0, messages: 0, totalResponseMs: 0, tokenUsage: 0, knowledgeFiles: 0, startedAt: Date.now(), errors: 0 };
|
|
293
|
-
private eventHandlers: Map<string, Function[]> = new Map();
|
|
294
|
-
private conversations: Map<string, Message[]> = new Map();
|
|
295
|
-
private requestCount = 0;
|
|
296
|
-
private llmLatencySum = 0;
|
|
297
|
-
private llmCalls = 0;
|
|
298
|
-
|
|
299
|
-
private emit(event: string, data: any): void {
|
|
300
|
-
const handlers = this.eventHandlers.get(event) ?? [];
|
|
301
|
-
for (const h of handlers) h(data);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
onConfigChange(handler: (config: any) => void): void {
|
|
305
|
-
const handlers = this.eventHandlers.get('config:change') ?? [];
|
|
306
|
-
handlers.push(handler);
|
|
307
|
-
this.eventHandlers.set('config:change', handlers);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
trackMessage(responseMs: number, tokens: number = 0): void {
|
|
311
|
-
this.stats.messages++;
|
|
312
|
-
this.stats.totalResponseMs += responseMs;
|
|
313
|
-
this.stats.tokenUsage += tokens;
|
|
314
|
-
this.requestCount++;
|
|
315
|
-
this.llmLatencySum += responseMs;
|
|
316
|
-
this.llmCalls++;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
trackError(): void { this.stats.errors++; }
|
|
320
|
-
|
|
321
|
-
trackSession(): void { this.stats.sessions++; }
|
|
322
|
-
|
|
323
|
-
constructor(port: number = 3000, authConfig?: AuthConfig) {
|
|
324
|
-
super();
|
|
325
|
-
this.port = port;
|
|
326
|
-
this.app = express();
|
|
327
|
-
this.app.use(express.json({ limit: '10mb' }));
|
|
328
|
-
if (authConfig && authConfig.apiKeys.length > 0) {
|
|
329
|
-
this.app.use(createAuthMiddleware(authConfig));
|
|
330
|
-
}
|
|
331
|
-
this.setupRoutes();
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
setAgentName(name: string): void {
|
|
335
|
-
this.agentName = name;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
onStreamMessage(handler: (msg: Message, res: Response) => Promise<void>): void {
|
|
339
|
-
this.streamHandler = handler;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private setupRoutes(): void {
|
|
343
|
-
this.app.get('/', (_req: Request, res: Response) => {
|
|
344
|
-
res.type('html').send(CHAT_HTML);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
this.app.get('/health', (_req: Request, res: Response) => {
|
|
348
|
-
res.json({ status: 'ok', timestamp: Date.now() });
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
this.app.get('/api/info', (_req: Request, res: Response) => {
|
|
352
|
-
res.json({ name: this.agentName });
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// Streaming chat endpoint
|
|
356
|
-
this.app.post('/api/chat', async (req: Request, res: Response) => {
|
|
357
|
-
const { message, sessionId } = req.body;
|
|
358
|
-
const sid = sessionId ?? 'default';
|
|
359
|
-
if (!message) {
|
|
360
|
-
res.status(400).json({ error: 'message is required' });
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const msg: Message = {
|
|
365
|
-
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
366
|
-
role: 'user',
|
|
367
|
-
content: message,
|
|
368
|
-
timestamp: Date.now(),
|
|
369
|
-
metadata: { sessionId: sid },
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
// Track conversation
|
|
373
|
-
if (!this.conversations.has(sid)) this.conversations.set(sid, []);
|
|
374
|
-
this.conversations.get(sid)!.push(msg);
|
|
375
|
-
|
|
376
|
-
if (this.streamHandler) {
|
|
377
|
-
try {
|
|
378
|
-
await this.streamHandler(msg, res);
|
|
379
|
-
} catch (err) {
|
|
380
|
-
if (!res.headersSent) {
|
|
381
|
-
res.status(500).json({ error: 'Internal error' });
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Fallback: non-streaming
|
|
388
|
-
if (!this.handler) {
|
|
389
|
-
res.status(503).json({ error: 'Agent not ready' });
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
const response = await this.handler(msg);
|
|
395
|
-
res.json({ response: response.content, id: response.id });
|
|
396
|
-
} catch (err) {
|
|
397
|
-
res.status(500).json({ error: 'Internal error' });
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
// --- Multi-LLM Config API ---
|
|
402
|
-
this.app.get('/api/config', (_req: Request, res: Response) => {
|
|
403
|
-
res.json({
|
|
404
|
-
provider: this.currentProvider,
|
|
405
|
-
providers: [
|
|
406
|
-
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] },
|
|
407
|
-
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
|
|
408
|
-
{ id: 'ollama', name: 'Ollama (Local)', baseUrl: 'http://localhost:11434/v1', models: ['llama3', 'mistral', 'codellama'] },
|
|
409
|
-
{ id: 'qwen', name: 'Qwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: ['qwen-turbo', 'qwen-plus', 'qwen-max'] },
|
|
410
|
-
{ id: 'anthropic', name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'] },
|
|
411
|
-
],
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
this.app.post('/api/config', (req: Request, res: Response) => {
|
|
416
|
-
const { provider, model, baseUrl, apiKey } = req.body;
|
|
417
|
-
if (provider) this.currentProvider = provider;
|
|
418
|
-
// Emit config change event for runtime to handle
|
|
419
|
-
this.emit('config:change', { provider, model, baseUrl, apiKey });
|
|
420
|
-
res.json({ ok: true, provider: this.currentProvider });
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// --- Dashboard ---
|
|
424
|
-
this.app.get('/dashboard', (_req: Request, res: Response) => {
|
|
425
|
-
res.type('html').send(DASHBOARD_HTML);
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
this.app.get('/api/dashboard', (_req: Request, res: Response) => {
|
|
429
|
-
res.json(this.stats);
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
// --- Knowledge Base Upload ---
|
|
433
|
-
this.app.post('/api/kb/upload', async (req: Request, res: Response) => {
|
|
434
|
-
try {
|
|
435
|
-
const { content, filename } = req.body;
|
|
436
|
-
if (!content) { res.status(400).json({ error: 'content required' }); return; }
|
|
437
|
-
const kb = new KnowledgeBase('.');
|
|
438
|
-
const result = await kb.addText(content, filename ?? 'upload');
|
|
439
|
-
this.stats.knowledgeFiles++;
|
|
440
|
-
res.json({ ok: true, chunks: result.chunks });
|
|
441
|
-
} catch (err) {
|
|
442
|
-
res.status(500).json({ error: err instanceof Error ? err.message : 'Failed' });
|
|
443
|
-
}
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
this.app.get('/api/kb/stats', (_req: Request, res: Response) => {
|
|
447
|
-
try {
|
|
448
|
-
const kb = new KnowledgeBase('.');
|
|
449
|
-
res.json(kb.getStats());
|
|
450
|
-
} catch { res.json({ totalEntries: 0, sources: [] }); }
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// --- Health Check (detailed) ---
|
|
454
|
-
this.app.get('/api/health', (_req: Request, res: Response) => {
|
|
455
|
-
const uptimeMs = Date.now() - this.stats.startedAt;
|
|
456
|
-
res.json({
|
|
457
|
-
status: 'ok',
|
|
458
|
-
timestamp: Date.now(),
|
|
459
|
-
uptime: uptimeMs,
|
|
460
|
-
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
|
461
|
-
version: '1.0.0',
|
|
462
|
-
agent: this.agentName,
|
|
463
|
-
stats: {
|
|
464
|
-
sessions: this.stats.sessions,
|
|
465
|
-
messages: this.stats.messages,
|
|
466
|
-
errors: this.stats.errors,
|
|
467
|
-
avgResponseMs: this.stats.messages > 0 ? Math.round(this.stats.totalResponseMs / this.stats.messages) : 0,
|
|
468
|
-
},
|
|
469
|
-
memory: {
|
|
470
|
-
rss: process.memoryUsage().rss,
|
|
471
|
-
heapUsed: process.memoryUsage().heapUsed,
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
// --- Prometheus Metrics ---
|
|
477
|
-
this.app.get('/api/metrics', (_req: Request, res: Response) => {
|
|
478
|
-
const uptimeMs = Date.now() - this.stats.startedAt;
|
|
479
|
-
const avgLatency = this.llmCalls > 0 ? this.llmLatencySum / this.llmCalls : 0;
|
|
480
|
-
const mem = process.memoryUsage();
|
|
481
|
-
res.type('text/plain').send(
|
|
482
|
-
`# HELP opc_uptime_seconds Agent uptime in seconds\n` +
|
|
483
|
-
`# TYPE opc_uptime_seconds gauge\n` +
|
|
484
|
-
`opc_uptime_seconds ${(uptimeMs / 1000).toFixed(1)}\n` +
|
|
485
|
-
`# HELP opc_requests_total Total requests\n` +
|
|
486
|
-
`# TYPE opc_requests_total counter\n` +
|
|
487
|
-
`opc_requests_total ${this.requestCount}\n` +
|
|
488
|
-
`# HELP opc_messages_total Total messages processed\n` +
|
|
489
|
-
`# TYPE opc_messages_total counter\n` +
|
|
490
|
-
`opc_messages_total ${this.stats.messages}\n` +
|
|
491
|
-
`# HELP opc_errors_total Total errors\n` +
|
|
492
|
-
`# TYPE opc_errors_total counter\n` +
|
|
493
|
-
`opc_errors_total ${this.stats.errors}\n` +
|
|
494
|
-
`# HELP opc_llm_latency_avg_ms Average LLM response latency\n` +
|
|
495
|
-
`# TYPE opc_llm_latency_avg_ms gauge\n` +
|
|
496
|
-
`opc_llm_latency_avg_ms ${avgLatency.toFixed(1)}\n` +
|
|
497
|
-
`# HELP opc_sessions_total Total sessions\n` +
|
|
498
|
-
`# TYPE opc_sessions_total counter\n` +
|
|
499
|
-
`opc_sessions_total ${this.stats.sessions}\n` +
|
|
500
|
-
`# HELP opc_token_usage_total Total token usage\n` +
|
|
501
|
-
`# TYPE opc_token_usage_total counter\n` +
|
|
502
|
-
`opc_token_usage_total ${this.stats.tokenUsage}\n` +
|
|
503
|
-
`# HELP process_resident_memory_bytes Resident memory size\n` +
|
|
504
|
-
`# TYPE process_resident_memory_bytes gauge\n` +
|
|
505
|
-
`process_resident_memory_bytes ${mem.rss}\n`
|
|
506
|
-
);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// --- Conversation tracking & export ---
|
|
510
|
-
this.app.get('/api/conversations/export', (req: Request, res: Response) => {
|
|
511
|
-
const sessionId = req.query.sessionId as string;
|
|
512
|
-
const format = (req.query.format as string) ?? 'json';
|
|
513
|
-
|
|
514
|
-
const messages = sessionId ? (this.conversations.get(sessionId) ?? []) : Array.from(this.conversations.values()).flat();
|
|
515
|
-
|
|
516
|
-
if (format === 'markdown') {
|
|
517
|
-
const md = messages.map(m => `**${m.role}** (${new Date(m.timestamp).toISOString()}):\n${m.content}`).join('\n\n---\n\n');
|
|
518
|
-
res.type('text/markdown').send(md);
|
|
519
|
-
} else if (format === 'csv') {
|
|
520
|
-
const header = 'id,role,content,timestamp\n';
|
|
521
|
-
const rows = messages.map(m => `"${m.id}","${m.role}","${m.content.replace(/"/g, '""')}",${m.timestamp}`).join('\n');
|
|
522
|
-
res.type('text/csv').send(header + rows);
|
|
523
|
-
} else {
|
|
524
|
-
res.json({ sessionId: sessionId ?? 'all', messages, count: messages.length });
|
|
525
|
-
}
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// --- Document Upload ---
|
|
529
|
-
this.app.post('/api/documents/upload', async (req: Request, res: Response) => {
|
|
530
|
-
try {
|
|
531
|
-
const { content, filename, mimeType } = req.body;
|
|
532
|
-
if (!content || !filename) {
|
|
533
|
-
res.status(400).json({ error: 'content and filename are required' });
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
const kb = new KnowledgeBase('.');
|
|
537
|
-
const result = await kb.addText(content, filename);
|
|
538
|
-
this.stats.knowledgeFiles++;
|
|
539
|
-
res.json({ ok: true, filename, chunks: result.chunks, chars: content.length });
|
|
540
|
-
} catch (err) {
|
|
541
|
-
res.status(500).json({ error: err instanceof Error ? err.message : 'Upload failed' });
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
// --- Agent Templates Gallery ---
|
|
546
|
-
this.app.get('/api/templates', (_req: Request, res: Response) => {
|
|
547
|
-
res.json({ templates: AGENT_TEMPLATES });
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
this.app.get('/templates', (_req: Request, res: Response) => {
|
|
551
|
-
res.type('html').send(TEMPLATES_HTML);
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
// Legacy endpoint
|
|
555
|
-
this.app.post('/chat', async (req: Request, res: Response) => {
|
|
556
|
-
if (!this.handler) {
|
|
557
|
-
res.status(503).json({ error: 'Agent not ready' });
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
const { message, sessionId } = req.body;
|
|
561
|
-
if (!message) {
|
|
562
|
-
res.status(400).json({ error: 'message is required' });
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
const msg: Message = {
|
|
566
|
-
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
567
|
-
role: 'user',
|
|
568
|
-
content: message,
|
|
569
|
-
timestamp: Date.now(),
|
|
570
|
-
metadata: { sessionId: sessionId ?? 'default' },
|
|
571
|
-
};
|
|
572
|
-
try {
|
|
573
|
-
const response = await this.handler(msg);
|
|
574
|
-
res.json({ response: response.content, id: response.id });
|
|
575
|
-
} catch {
|
|
576
|
-
res.status(500).json({ error: 'Internal error' });
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async start(): Promise<void> {
|
|
582
|
-
return new Promise((resolve) => {
|
|
583
|
-
this.server = this.app.listen(this.port, () => {
|
|
584
|
-
console.log(`[WebChannel] Listening on http://localhost:${this.port}`);
|
|
585
|
-
resolve();
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
async stop(): Promise<void> {
|
|
591
|
-
return new Promise((resolve, reject) => {
|
|
592
|
-
if (!this.server) return resolve();
|
|
593
|
-
this.server.close((err) => (err ? reject(err) : resolve()));
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
1
|
+
import express, { type Express, type Request, type Response } from 'express';
|
|
2
|
+
import type { Server } from 'http';
|
|
3
|
+
import type { Message } from '../core/types';
|
|
4
|
+
import { BaseChannel } from './index';
|
|
5
|
+
import { KnowledgeBase } from '../core/knowledge';
|
|
6
|
+
import { createProvider, type LLMProvider } from '../providers';
|
|
7
|
+
import { createAuthMiddleware, type AuthConfig } from '../core/auth';
|
|
8
|
+
|
|
9
|
+
const AGENT_TEMPLATES = [
|
|
10
|
+
{ id: 'customer-service', name: 'Customer Service', description: 'Handle support tickets, FAQs, and customer inquiries', icon: '🎧', category: 'Business' },
|
|
11
|
+
{ id: 'code-reviewer', name: 'Code Reviewer', description: 'Review PRs, suggest improvements, check for bugs', icon: '🔍', category: 'Engineering' },
|
|
12
|
+
{ id: 'content-writer', name: 'Content Writer', description: 'Write blogs, social media posts, and marketing copy', icon: '✍️', category: 'Marketing' },
|
|
13
|
+
{ id: 'executive-assistant', name: 'Executive Assistant', description: 'Schedule management, email drafting, meeting prep', icon: '📋', category: 'Business' },
|
|
14
|
+
{ id: 'knowledge-base', name: 'Knowledge Base', description: 'RAG-powered Q&A over your documents', icon: '📚', category: 'Knowledge' },
|
|
15
|
+
{ id: 'project-manager', name: 'Project Manager', description: 'Track tasks, milestones, and team coordination', icon: '📊', category: 'Business' },
|
|
16
|
+
{ id: 'sales-assistant', name: 'Sales Assistant', description: 'Lead qualification, outreach drafting, CRM updates', icon: '💼', category: 'Sales' },
|
|
17
|
+
{ id: 'financial-advisor', name: 'Financial Advisor', description: 'Budget analysis, financial planning, cost optimization', icon: '💰', category: 'Finance' },
|
|
18
|
+
{ id: 'hr-recruiter', name: 'HR Recruiter', description: 'Resume screening, interview scheduling, candidate comms', icon: '👥', category: 'HR' },
|
|
19
|
+
{ id: 'legal-assistant', name: 'Legal Assistant', description: 'Contract review, compliance checks, legal research', icon: '⚖️', category: 'Legal' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const TEMPLATES_HTML = `<!DOCTYPE html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
26
|
+
<title>Agent Templates</title>
|
|
27
|
+
<style>
|
|
28
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
29
|
+
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px}
|
|
30
|
+
h1{font-size:28px;margin-bottom:8px;color:#fff}
|
|
31
|
+
.sub{color:#888;margin-bottom:32px;font-size:14px}
|
|
32
|
+
nav{margin-bottom:24px}
|
|
33
|
+
nav a{color:#818cf8;text-decoration:none;margin-right:16px;font-size:14px}
|
|
34
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
|
|
35
|
+
.card{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:24px;cursor:pointer;transition:all .2s}
|
|
36
|
+
.card:hover{border-color:#818cf8;transform:translateY(-2px)}
|
|
37
|
+
.card .icon{font-size:32px;margin-bottom:12px}
|
|
38
|
+
.card h3{font-size:16px;color:#fff;margin-bottom:8px}
|
|
39
|
+
.card p{font-size:13px;color:#888;line-height:1.5}
|
|
40
|
+
.card .cat{font-size:11px;color:#818cf8;text-transform:uppercase;letter-spacing:1px;margin-top:12px}
|
|
41
|
+
.btn{display:inline-block;background:#2563eb;color:#fff;border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;margin-top:12px}
|
|
42
|
+
.btn:hover{background:#1d4ed8}
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<nav><a href="/">← Chat</a><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
|
|
47
|
+
<h1>🧩 Agent Templates</h1>
|
|
48
|
+
<p class="sub">Create a new agent from a pre-built template in one click.</p>
|
|
49
|
+
<div class="grid" id="grid"></div>
|
|
50
|
+
<script>
|
|
51
|
+
fetch('/api/templates').then(r=>r.json()).then(d=>{
|
|
52
|
+
const g=document.getElementById('grid');
|
|
53
|
+
d.templates.forEach(t=>{
|
|
54
|
+
g.innerHTML+=\`<div class="card"><div class="icon">\${t.icon}</div><h3>\${t.name}</h3><p>\${t.description}</p><div class="cat">\${t.category}</div><button class="btn" onclick="alert('Creating agent from template: '+'\${t.id}'+'\\\\nRun: opc init --template \${t.id}')">Use Template</button></div>\`;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>`;
|
|
60
|
+
|
|
61
|
+
const CHAT_HTML = `<!DOCTYPE html>
|
|
62
|
+
<html lang="en">
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="UTF-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
66
|
+
<title>OPC Agent</title>
|
|
67
|
+
<style>
|
|
68
|
+
:root{--bg:#0a0a0f;--surface:#12121a;--border:#1e1e2e;--text:#e0e0e0;--text-dim:#888;--accent:#818cf8;--accent-hover:#6366f1;--user-bg:#2563eb;--user-hover:#1d4ed8;--error-bg:#7f1d1d;--error-text:#fca5a5;--success:#22c55e;--radius:12px}
|
|
69
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
70
|
+
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
|
|
71
|
+
header{background:var(--surface);padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;backdrop-filter:blur(12px)}
|
|
72
|
+
header .avatar{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#6366f1);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
|
73
|
+
header .info{flex:1;min-width:0}
|
|
74
|
+
header h1{font-size:16px;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
75
|
+
header .status{font-size:12px;color:var(--success);display:flex;align-items:center;gap:4px}
|
|
76
|
+
header .status .dot{width:6px;height:6px;border-radius:50%;background:var(--success);animation:pulse 2s infinite}
|
|
77
|
+
nav.header-nav{display:flex;gap:4px}
|
|
78
|
+
nav.header-nav a{color:var(--text-dim);text-decoration:none;font-size:12px;padding:4px 10px;border-radius:6px;transition:all .2s}
|
|
79
|
+
nav.header-nav a:hover{color:#fff;background:rgba(255,255,255,.06)}
|
|
80
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
81
|
+
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
82
|
+
@keyframes slideIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
|
|
83
|
+
#messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:12px;scroll-behavior:smooth}
|
|
84
|
+
#messages::-webkit-scrollbar{width:4px}
|
|
85
|
+
#messages::-webkit-scrollbar-track{background:transparent}
|
|
86
|
+
#messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
|
87
|
+
.msg-wrap{display:flex;flex-direction:column;animation:fadeIn .3s ease-out}
|
|
88
|
+
.msg-wrap.user{align-items:flex-end}
|
|
89
|
+
.msg-wrap.assistant{align-items:flex-start}
|
|
90
|
+
.msg{max-width:min(720px,85%);padding:10px 14px;border-radius:var(--radius);line-height:1.7;font-size:14px;word-break:break-word;position:relative;transition:all .2s}
|
|
91
|
+
.msg.user{background:var(--user-bg);color:#fff;border-bottom-right-radius:4px}
|
|
92
|
+
.msg.assistant{background:var(--surface);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px}
|
|
93
|
+
.msg.error{background:var(--error-bg);color:var(--error-text);border:1px solid rgba(239,68,68,.3)}
|
|
94
|
+
.msg pre{background:rgba(0,0,0,.4);padding:12px;border-radius:8px;overflow-x:auto;margin:8px 0;font-size:13px;font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;line-height:1.5}
|
|
95
|
+
.msg code{font-family:'JetBrains Mono','Fira Code','Cascadia Code',monospace;font-size:13px;background:rgba(0,0,0,.3);padding:1px 5px;border-radius:4px}
|
|
96
|
+
.msg pre code{background:none;padding:0}
|
|
97
|
+
.msg .cursor{display:inline-block;width:2px;height:14px;background:var(--accent);animation:blink .6s infinite;vertical-align:text-bottom;margin-left:2px}
|
|
98
|
+
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
|
|
99
|
+
.typing{display:flex;gap:4px;padding:12px 16px;align-items:center}
|
|
100
|
+
.typing span{width:6px;height:6px;border-radius:50%;background:var(--text-dim);animation:typingDot 1.4s infinite}
|
|
101
|
+
.typing span:nth-child(2){animation-delay:.2s}
|
|
102
|
+
.typing span:nth-child(3){animation-delay:.4s}
|
|
103
|
+
@keyframes typingDot{0%,60%,100%{opacity:.3;transform:scale(.8)}30%{opacity:1;transform:scale(1)}}
|
|
104
|
+
.reactions{display:flex;gap:4px;margin-top:4px}
|
|
105
|
+
.reactions button{background:rgba(255,255,255,.06);border:1px solid transparent;border-radius:16px;padding:2px 8px;font-size:13px;cursor:pointer;transition:all .15s;color:var(--text-dim)}
|
|
106
|
+
.reactions button:hover{background:rgba(255,255,255,.12);border-color:var(--border)}
|
|
107
|
+
.reactions button.active{background:rgba(99,102,241,.2);border-color:var(--accent);color:var(--accent)}
|
|
108
|
+
.msg-time{font-size:11px;color:var(--text-dim);margin-top:2px;opacity:0;transition:opacity .2s}
|
|
109
|
+
.msg-wrap:hover .msg-time{opacity:1}
|
|
110
|
+
.attachment{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.3);padding:8px 12px;border-radius:8px;margin-top:6px;font-size:13px}
|
|
111
|
+
.attachment .icon{font-size:18px}
|
|
112
|
+
#input-area{background:var(--surface);padding:12px 20px 16px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:flex-end;flex-shrink:0}
|
|
113
|
+
#input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;color:#fff;font-size:14px;outline:none;resize:none;max-height:150px;min-height:42px;font-family:inherit;line-height:1.5;transition:border-color .2s}
|
|
114
|
+
#input:focus{border-color:var(--accent)}
|
|
115
|
+
#input::placeholder{color:var(--text-dim)}
|
|
116
|
+
#send{background:var(--user-bg);color:#fff;border:none;border-radius:var(--radius);width:42px;height:42px;font-size:18px;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
117
|
+
#send:hover{background:var(--user-hover);transform:scale(1.05)}
|
|
118
|
+
#send:disabled{background:#334155;cursor:not-allowed;transform:none}
|
|
119
|
+
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-dim);gap:12px;padding:40px;text-align:center}
|
|
120
|
+
.empty-state .logo{font-size:48px;opacity:.6}
|
|
121
|
+
.empty-state h2{color:var(--text);font-size:20px;font-weight:500}
|
|
122
|
+
.empty-state p{font-size:14px;max-width:400px;line-height:1.6}
|
|
123
|
+
@media(max-width:640px){
|
|
124
|
+
header{padding:10px 14px}
|
|
125
|
+
#messages{padding:12px}
|
|
126
|
+
#input-area{padding:10px 14px 14px}
|
|
127
|
+
.msg{max-width:90%;font-size:14px}
|
|
128
|
+
nav.header-nav{display:none}
|
|
129
|
+
}
|
|
130
|
+
</style>
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
<header>
|
|
134
|
+
<div class="avatar" id="avatar">🤖</div>
|
|
135
|
+
<div class="info"><h1 id="title">OPC Agent</h1><div class="status"><span class="dot"></span>Online</div></div>
|
|
136
|
+
<nav class="header-nav"><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
|
|
137
|
+
</header>
|
|
138
|
+
<div id="messages">
|
|
139
|
+
<div class="empty-state" id="empty"><div class="logo">💬</div><h2>Start a conversation</h2><p>Type a message below to chat with your AI agent.</p></div>
|
|
140
|
+
</div>
|
|
141
|
+
<div id="input-area">
|
|
142
|
+
<textarea id="input" rows="1" placeholder="Type a message…" autocomplete="off"></textarea>
|
|
143
|
+
<button id="send" aria-label="Send">↑</button>
|
|
144
|
+
</div>
|
|
145
|
+
<script>
|
|
146
|
+
const msgs=document.getElementById('messages'),input=document.getElementById('input'),btn=document.getElementById('send'),empty=document.getElementById('empty');
|
|
147
|
+
let sessionId=crypto.randomUUID(),sending=false;
|
|
148
|
+
|
|
149
|
+
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
150
|
+
function fmtTime(){return new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
|
|
151
|
+
function renderMd(text){
|
|
152
|
+
let h=esc(text);
|
|
153
|
+
h=h.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g,'<pre><code>$2</code></pre>');
|
|
154
|
+
h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
|
|
155
|
+
h=h.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>');
|
|
156
|
+
h=h.replace(/\\n/g,'<br>');
|
|
157
|
+
return h;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function addMsg(role,text,opts){
|
|
161
|
+
if(empty)empty.remove();
|
|
162
|
+
const wrap=document.createElement('div');
|
|
163
|
+
wrap.className='msg-wrap '+role;
|
|
164
|
+
const d=document.createElement('div');
|
|
165
|
+
d.className='msg '+role;
|
|
166
|
+
if(opts?.html)d.innerHTML=text;else if(role==='assistant'&&text)d.innerHTML=renderMd(text);else d.textContent=text;
|
|
167
|
+
wrap.appendChild(d);
|
|
168
|
+
const time=document.createElement('div');
|
|
169
|
+
time.className='msg-time';
|
|
170
|
+
time.textContent=fmtTime();
|
|
171
|
+
wrap.appendChild(time);
|
|
172
|
+
if(role==='assistant'&&text){
|
|
173
|
+
const rx=document.createElement('div');rx.className='reactions';
|
|
174
|
+
rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
|
|
175
|
+
wrap.appendChild(rx);
|
|
176
|
+
}
|
|
177
|
+
msgs.appendChild(wrap);
|
|
178
|
+
msgs.scrollTop=msgs.scrollHeight;
|
|
179
|
+
return d;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
window.react=function(el){el.classList.toggle('active')};
|
|
183
|
+
|
|
184
|
+
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
|
|
185
|
+
input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,150)+'px'});
|
|
186
|
+
btn.addEventListener('click',send);
|
|
187
|
+
|
|
188
|
+
async function send(){
|
|
189
|
+
const text=input.value.trim();
|
|
190
|
+
if(!text||sending)return;
|
|
191
|
+
sending=true;btn.disabled=true;
|
|
192
|
+
input.value='';input.style.height='auto';
|
|
193
|
+
addMsg('user',text);
|
|
194
|
+
const wrap=document.createElement('div');wrap.className='msg-wrap assistant';
|
|
195
|
+
const d=document.createElement('div');d.className='msg assistant';
|
|
196
|
+
d.innerHTML='<div class="typing"><span></span><span></span><span></span></div>';
|
|
197
|
+
wrap.appendChild(d);
|
|
198
|
+
const time=document.createElement('div');time.className='msg-time';time.textContent=fmtTime();
|
|
199
|
+
wrap.appendChild(time);
|
|
200
|
+
msgs.appendChild(wrap);msgs.scrollTop=msgs.scrollHeight;
|
|
201
|
+
try{
|
|
202
|
+
const res=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,sessionId})});
|
|
203
|
+
if(!res.ok)throw new Error('HTTP '+res.status);
|
|
204
|
+
const reader=res.body.getReader(),dec=new TextDecoder();
|
|
205
|
+
let full='';
|
|
206
|
+
while(true){
|
|
207
|
+
const{done,value}=await reader.read();
|
|
208
|
+
if(done)break;
|
|
209
|
+
const chunk=dec.decode(value,{stream:true});
|
|
210
|
+
const lines=chunk.split('\\n');
|
|
211
|
+
for(const line of lines){
|
|
212
|
+
if(!line.startsWith('data: '))continue;
|
|
213
|
+
const dd=line.slice(6);if(dd==='[DONE]')continue;
|
|
214
|
+
try{const j=JSON.parse(dd);if(j.content)full+=j.content;if(j.error)full='Error: '+j.error;}catch{}
|
|
215
|
+
}
|
|
216
|
+
d.innerHTML=renderMd(full)+'<span class="cursor"></span>';
|
|
217
|
+
msgs.scrollTop=msgs.scrollHeight;
|
|
218
|
+
}
|
|
219
|
+
if(!full){d.textContent='(empty response)';}else{d.innerHTML=renderMd(full);}
|
|
220
|
+
const rx=document.createElement('div');rx.className='reactions';
|
|
221
|
+
rx.innerHTML='<button data-r="👍" onclick="react(this)">👍</button><button data-r="👎" onclick="react(this)">👎</button>';
|
|
222
|
+
wrap.appendChild(rx);
|
|
223
|
+
}catch(e){d.className='msg error';d.textContent='Error: '+e.message;}
|
|
224
|
+
sending=false;btn.disabled=false;input.focus();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fetch('/api/info').then(r=>r.json()).then(d=>{if(d.name)document.getElementById('title').textContent=d.name}).catch(()=>{});
|
|
228
|
+
</script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>`;
|
|
231
|
+
|
|
232
|
+
const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
233
|
+
<html lang="en">
|
|
234
|
+
<head>
|
|
235
|
+
<meta charset="UTF-8">
|
|
236
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
237
|
+
<title>OPC Dashboard</title>
|
|
238
|
+
<style>
|
|
239
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
240
|
+
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px}
|
|
241
|
+
h1{font-size:24px;margin-bottom:24px;color:#fff}
|
|
242
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:32px}
|
|
243
|
+
.card{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:20px}
|
|
244
|
+
.card .label{font-size:12px;color:#888;text-transform:uppercase;letter-spacing:1px}
|
|
245
|
+
.card .value{font-size:32px;font-weight:700;color:#818cf8;margin-top:4px}
|
|
246
|
+
.card .sub{font-size:12px;color:#555;margin-top:4px}
|
|
247
|
+
nav{margin-bottom:24px}
|
|
248
|
+
nav a{color:#818cf8;text-decoration:none;margin-right:16px;font-size:14px}
|
|
249
|
+
nav a:hover{text-decoration:underline}
|
|
250
|
+
.chart{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:20px;margin-bottom:16px}
|
|
251
|
+
.chart h3{font-size:14px;color:#888;margin-bottom:12px}
|
|
252
|
+
</style>
|
|
253
|
+
</head>
|
|
254
|
+
<body>
|
|
255
|
+
<nav><a href="/">← Chat</a><a href="/dashboard">Dashboard</a></nav>
|
|
256
|
+
<h1>📊 Agent Dashboard</h1>
|
|
257
|
+
<div class="grid">
|
|
258
|
+
<div class="card"><div class="label">Sessions</div><div class="value" id="sessions">0</div></div>
|
|
259
|
+
<div class="card"><div class="label">Messages</div><div class="value" id="messages">0</div></div>
|
|
260
|
+
<div class="card"><div class="label">Avg Response</div><div class="value" id="avgMs">0ms</div></div>
|
|
261
|
+
<div class="card"><div class="label">Token Usage</div><div class="value" id="tokens">0</div></div>
|
|
262
|
+
<div class="card"><div class="label">Uptime</div><div class="value" id="uptime">0m</div></div>
|
|
263
|
+
<div class="card"><div class="label">Knowledge Files</div><div class="value" id="kb">0</div></div>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="chart"><h3>Messages Over Time</h3><svg id="chart" width="100%" height="120" viewBox="0 0 600 120"></svg></div>
|
|
266
|
+
<script>
|
|
267
|
+
async function refresh(){
|
|
268
|
+
try{
|
|
269
|
+
const r=await fetch('/api/dashboard');const d=await r.json();
|
|
270
|
+
document.getElementById('sessions').textContent=d.sessions;
|
|
271
|
+
document.getElementById('messages').textContent=d.messages;
|
|
272
|
+
document.getElementById('avgMs').textContent=d.messages>0?Math.round(d.totalResponseMs/d.messages)+'ms':'0ms';
|
|
273
|
+
document.getElementById('tokens').textContent=d.tokenUsage.toLocaleString();
|
|
274
|
+
document.getElementById('kb').textContent=d.knowledgeFiles;
|
|
275
|
+
const mins=Math.round((Date.now()-d.startedAt)/60000);
|
|
276
|
+
document.getElementById('uptime').textContent=mins<60?mins+'m':Math.round(mins/60)+'h '+mins%60+'m';
|
|
277
|
+
}catch{}
|
|
278
|
+
}
|
|
279
|
+
refresh();setInterval(refresh,5000);
|
|
280
|
+
</script>
|
|
281
|
+
</body>
|
|
282
|
+
</html>`;
|
|
283
|
+
|
|
284
|
+
export class WebChannel extends BaseChannel {
|
|
285
|
+
readonly type = 'web';
|
|
286
|
+
private app: Express;
|
|
287
|
+
private server: Server | null = null;
|
|
288
|
+
private port: number;
|
|
289
|
+
private streamHandler: ((msg: Message, res: Response) => Promise<void>) | null = null;
|
|
290
|
+
private agentName: string = 'OPC Agent';
|
|
291
|
+
private currentProvider: string = 'openai';
|
|
292
|
+
private stats = { sessions: 0, messages: 0, totalResponseMs: 0, tokenUsage: 0, knowledgeFiles: 0, startedAt: Date.now(), errors: 0 };
|
|
293
|
+
private eventHandlers: Map<string, Function[]> = new Map();
|
|
294
|
+
private conversations: Map<string, Message[]> = new Map();
|
|
295
|
+
private requestCount = 0;
|
|
296
|
+
private llmLatencySum = 0;
|
|
297
|
+
private llmCalls = 0;
|
|
298
|
+
|
|
299
|
+
private emit(event: string, data: any): void {
|
|
300
|
+
const handlers = this.eventHandlers.get(event) ?? [];
|
|
301
|
+
for (const h of handlers) h(data);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
onConfigChange(handler: (config: any) => void): void {
|
|
305
|
+
const handlers = this.eventHandlers.get('config:change') ?? [];
|
|
306
|
+
handlers.push(handler);
|
|
307
|
+
this.eventHandlers.set('config:change', handlers);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
trackMessage(responseMs: number, tokens: number = 0): void {
|
|
311
|
+
this.stats.messages++;
|
|
312
|
+
this.stats.totalResponseMs += responseMs;
|
|
313
|
+
this.stats.tokenUsage += tokens;
|
|
314
|
+
this.requestCount++;
|
|
315
|
+
this.llmLatencySum += responseMs;
|
|
316
|
+
this.llmCalls++;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
trackError(): void { this.stats.errors++; }
|
|
320
|
+
|
|
321
|
+
trackSession(): void { this.stats.sessions++; }
|
|
322
|
+
|
|
323
|
+
constructor(port: number = 3000, authConfig?: AuthConfig) {
|
|
324
|
+
super();
|
|
325
|
+
this.port = port;
|
|
326
|
+
this.app = express();
|
|
327
|
+
this.app.use(express.json({ limit: '10mb' }));
|
|
328
|
+
if (authConfig && authConfig.apiKeys.length > 0) {
|
|
329
|
+
this.app.use(createAuthMiddleware(authConfig));
|
|
330
|
+
}
|
|
331
|
+
this.setupRoutes();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
setAgentName(name: string): void {
|
|
335
|
+
this.agentName = name;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
onStreamMessage(handler: (msg: Message, res: Response) => Promise<void>): void {
|
|
339
|
+
this.streamHandler = handler;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private setupRoutes(): void {
|
|
343
|
+
this.app.get('/', (_req: Request, res: Response) => {
|
|
344
|
+
res.type('html').send(CHAT_HTML);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
this.app.get('/health', (_req: Request, res: Response) => {
|
|
348
|
+
res.json({ status: 'ok', timestamp: Date.now() });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
this.app.get('/api/info', (_req: Request, res: Response) => {
|
|
352
|
+
res.json({ name: this.agentName });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Streaming chat endpoint
|
|
356
|
+
this.app.post('/api/chat', async (req: Request, res: Response) => {
|
|
357
|
+
const { message, sessionId } = req.body;
|
|
358
|
+
const sid = sessionId ?? 'default';
|
|
359
|
+
if (!message) {
|
|
360
|
+
res.status(400).json({ error: 'message is required' });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const msg: Message = {
|
|
365
|
+
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
366
|
+
role: 'user',
|
|
367
|
+
content: message,
|
|
368
|
+
timestamp: Date.now(),
|
|
369
|
+
metadata: { sessionId: sid },
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Track conversation
|
|
373
|
+
if (!this.conversations.has(sid)) this.conversations.set(sid, []);
|
|
374
|
+
this.conversations.get(sid)!.push(msg);
|
|
375
|
+
|
|
376
|
+
if (this.streamHandler) {
|
|
377
|
+
try {
|
|
378
|
+
await this.streamHandler(msg, res);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (!res.headersSent) {
|
|
381
|
+
res.status(500).json({ error: 'Internal error' });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Fallback: non-streaming
|
|
388
|
+
if (!this.handler) {
|
|
389
|
+
res.status(503).json({ error: 'Agent not ready' });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const response = await this.handler(msg);
|
|
395
|
+
res.json({ response: response.content, id: response.id });
|
|
396
|
+
} catch (err) {
|
|
397
|
+
res.status(500).json({ error: 'Internal error' });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// --- Multi-LLM Config API ---
|
|
402
|
+
this.app.get('/api/config', (_req: Request, res: Response) => {
|
|
403
|
+
res.json({
|
|
404
|
+
provider: this.currentProvider,
|
|
405
|
+
providers: [
|
|
406
|
+
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] },
|
|
407
|
+
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
|
|
408
|
+
{ id: 'ollama', name: 'Ollama (Local)', baseUrl: 'http://localhost:11434/v1', models: ['llama3', 'mistral', 'codellama'] },
|
|
409
|
+
{ id: 'qwen', name: 'Qwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: ['qwen-turbo', 'qwen-plus', 'qwen-max'] },
|
|
410
|
+
{ id: 'anthropic', name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'] },
|
|
411
|
+
],
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
this.app.post('/api/config', (req: Request, res: Response) => {
|
|
416
|
+
const { provider, model, baseUrl, apiKey } = req.body;
|
|
417
|
+
if (provider) this.currentProvider = provider;
|
|
418
|
+
// Emit config change event for runtime to handle
|
|
419
|
+
this.emit('config:change', { provider, model, baseUrl, apiKey });
|
|
420
|
+
res.json({ ok: true, provider: this.currentProvider });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// --- Dashboard ---
|
|
424
|
+
this.app.get('/dashboard', (_req: Request, res: Response) => {
|
|
425
|
+
res.type('html').send(DASHBOARD_HTML);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
this.app.get('/api/dashboard', (_req: Request, res: Response) => {
|
|
429
|
+
res.json(this.stats);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// --- Knowledge Base Upload ---
|
|
433
|
+
this.app.post('/api/kb/upload', async (req: Request, res: Response) => {
|
|
434
|
+
try {
|
|
435
|
+
const { content, filename } = req.body;
|
|
436
|
+
if (!content) { res.status(400).json({ error: 'content required' }); return; }
|
|
437
|
+
const kb = new KnowledgeBase('.');
|
|
438
|
+
const result = await kb.addText(content, filename ?? 'upload');
|
|
439
|
+
this.stats.knowledgeFiles++;
|
|
440
|
+
res.json({ ok: true, chunks: result.chunks });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
res.status(500).json({ error: err instanceof Error ? err.message : 'Failed' });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.app.get('/api/kb/stats', (_req: Request, res: Response) => {
|
|
447
|
+
try {
|
|
448
|
+
const kb = new KnowledgeBase('.');
|
|
449
|
+
res.json(kb.getStats());
|
|
450
|
+
} catch { res.json({ totalEntries: 0, sources: [] }); }
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// --- Health Check (detailed) ---
|
|
454
|
+
this.app.get('/api/health', (_req: Request, res: Response) => {
|
|
455
|
+
const uptimeMs = Date.now() - this.stats.startedAt;
|
|
456
|
+
res.json({
|
|
457
|
+
status: 'ok',
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
uptime: uptimeMs,
|
|
460
|
+
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
|
461
|
+
version: '1.0.0',
|
|
462
|
+
agent: this.agentName,
|
|
463
|
+
stats: {
|
|
464
|
+
sessions: this.stats.sessions,
|
|
465
|
+
messages: this.stats.messages,
|
|
466
|
+
errors: this.stats.errors,
|
|
467
|
+
avgResponseMs: this.stats.messages > 0 ? Math.round(this.stats.totalResponseMs / this.stats.messages) : 0,
|
|
468
|
+
},
|
|
469
|
+
memory: {
|
|
470
|
+
rss: process.memoryUsage().rss,
|
|
471
|
+
heapUsed: process.memoryUsage().heapUsed,
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// --- Prometheus Metrics ---
|
|
477
|
+
this.app.get('/api/metrics', (_req: Request, res: Response) => {
|
|
478
|
+
const uptimeMs = Date.now() - this.stats.startedAt;
|
|
479
|
+
const avgLatency = this.llmCalls > 0 ? this.llmLatencySum / this.llmCalls : 0;
|
|
480
|
+
const mem = process.memoryUsage();
|
|
481
|
+
res.type('text/plain').send(
|
|
482
|
+
`# HELP opc_uptime_seconds Agent uptime in seconds\n` +
|
|
483
|
+
`# TYPE opc_uptime_seconds gauge\n` +
|
|
484
|
+
`opc_uptime_seconds ${(uptimeMs / 1000).toFixed(1)}\n` +
|
|
485
|
+
`# HELP opc_requests_total Total requests\n` +
|
|
486
|
+
`# TYPE opc_requests_total counter\n` +
|
|
487
|
+
`opc_requests_total ${this.requestCount}\n` +
|
|
488
|
+
`# HELP opc_messages_total Total messages processed\n` +
|
|
489
|
+
`# TYPE opc_messages_total counter\n` +
|
|
490
|
+
`opc_messages_total ${this.stats.messages}\n` +
|
|
491
|
+
`# HELP opc_errors_total Total errors\n` +
|
|
492
|
+
`# TYPE opc_errors_total counter\n` +
|
|
493
|
+
`opc_errors_total ${this.stats.errors}\n` +
|
|
494
|
+
`# HELP opc_llm_latency_avg_ms Average LLM response latency\n` +
|
|
495
|
+
`# TYPE opc_llm_latency_avg_ms gauge\n` +
|
|
496
|
+
`opc_llm_latency_avg_ms ${avgLatency.toFixed(1)}\n` +
|
|
497
|
+
`# HELP opc_sessions_total Total sessions\n` +
|
|
498
|
+
`# TYPE opc_sessions_total counter\n` +
|
|
499
|
+
`opc_sessions_total ${this.stats.sessions}\n` +
|
|
500
|
+
`# HELP opc_token_usage_total Total token usage\n` +
|
|
501
|
+
`# TYPE opc_token_usage_total counter\n` +
|
|
502
|
+
`opc_token_usage_total ${this.stats.tokenUsage}\n` +
|
|
503
|
+
`# HELP process_resident_memory_bytes Resident memory size\n` +
|
|
504
|
+
`# TYPE process_resident_memory_bytes gauge\n` +
|
|
505
|
+
`process_resident_memory_bytes ${mem.rss}\n`
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// --- Conversation tracking & export ---
|
|
510
|
+
this.app.get('/api/conversations/export', (req: Request, res: Response) => {
|
|
511
|
+
const sessionId = req.query.sessionId as string;
|
|
512
|
+
const format = (req.query.format as string) ?? 'json';
|
|
513
|
+
|
|
514
|
+
const messages = sessionId ? (this.conversations.get(sessionId) ?? []) : Array.from(this.conversations.values()).flat();
|
|
515
|
+
|
|
516
|
+
if (format === 'markdown') {
|
|
517
|
+
const md = messages.map(m => `**${m.role}** (${new Date(m.timestamp).toISOString()}):\n${m.content}`).join('\n\n---\n\n');
|
|
518
|
+
res.type('text/markdown').send(md);
|
|
519
|
+
} else if (format === 'csv') {
|
|
520
|
+
const header = 'id,role,content,timestamp\n';
|
|
521
|
+
const rows = messages.map(m => `"${m.id}","${m.role}","${m.content.replace(/"/g, '""')}",${m.timestamp}`).join('\n');
|
|
522
|
+
res.type('text/csv').send(header + rows);
|
|
523
|
+
} else {
|
|
524
|
+
res.json({ sessionId: sessionId ?? 'all', messages, count: messages.length });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// --- Document Upload ---
|
|
529
|
+
this.app.post('/api/documents/upload', async (req: Request, res: Response) => {
|
|
530
|
+
try {
|
|
531
|
+
const { content, filename, mimeType } = req.body;
|
|
532
|
+
if (!content || !filename) {
|
|
533
|
+
res.status(400).json({ error: 'content and filename are required' });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const kb = new KnowledgeBase('.');
|
|
537
|
+
const result = await kb.addText(content, filename);
|
|
538
|
+
this.stats.knowledgeFiles++;
|
|
539
|
+
res.json({ ok: true, filename, chunks: result.chunks, chars: content.length });
|
|
540
|
+
} catch (err) {
|
|
541
|
+
res.status(500).json({ error: err instanceof Error ? err.message : 'Upload failed' });
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// --- Agent Templates Gallery ---
|
|
546
|
+
this.app.get('/api/templates', (_req: Request, res: Response) => {
|
|
547
|
+
res.json({ templates: AGENT_TEMPLATES });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
this.app.get('/templates', (_req: Request, res: Response) => {
|
|
551
|
+
res.type('html').send(TEMPLATES_HTML);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Legacy endpoint
|
|
555
|
+
this.app.post('/chat', async (req: Request, res: Response) => {
|
|
556
|
+
if (!this.handler) {
|
|
557
|
+
res.status(503).json({ error: 'Agent not ready' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const { message, sessionId } = req.body;
|
|
561
|
+
if (!message) {
|
|
562
|
+
res.status(400).json({ error: 'message is required' });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const msg: Message = {
|
|
566
|
+
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
567
|
+
role: 'user',
|
|
568
|
+
content: message,
|
|
569
|
+
timestamp: Date.now(),
|
|
570
|
+
metadata: { sessionId: sessionId ?? 'default' },
|
|
571
|
+
};
|
|
572
|
+
try {
|
|
573
|
+
const response = await this.handler(msg);
|
|
574
|
+
res.json({ response: response.content, id: response.id });
|
|
575
|
+
} catch {
|
|
576
|
+
res.status(500).json({ error: 'Internal error' });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async start(): Promise<void> {
|
|
582
|
+
return new Promise((resolve) => {
|
|
583
|
+
this.server = this.app.listen(this.port, () => {
|
|
584
|
+
console.log(`[WebChannel] Listening on http://localhost:${this.port}`);
|
|
585
|
+
resolve();
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async stop(): Promise<void> {
|
|
591
|
+
return new Promise((resolve, reject) => {
|
|
592
|
+
if (!this.server) return resolve();
|
|
593
|
+
this.server.close((err) => (err ? reject(err) : resolve()));
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|