syntropy 0.39.0 → 0.41.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/TODO.md +44 -142
- data/cmd/console.rb +0 -2
- data/cmd/new/template/config/production.rb +2 -1
- data/cmd/new.rb +1 -1
- data/cmd/serve.rb +26 -0
- data/examples/agent/Dockerfile +25 -0
- data/examples/agent/Gemfile +3 -0
- data/examples/agent/README.md +40 -0
- data/examples/agent/TODO.md +5 -0
- data/examples/agent/app/_layout/default.rb +15 -0
- data/examples/agent/app/_lib/storage.rb +13 -0
- data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/agent/app/assets/agent.js +63 -0
- data/examples/agent/app/assets/minigfm.js +207 -0
- data/examples/agent/app/assets/model.js +45 -0
- data/examples/agent/app/assets/style.css +25 -0
- data/examples/agent/app/assets/tools.js +74 -0
- data/examples/agent/app/assets/ui.js +50 -0
- data/examples/agent/app/index.rb +19 -0
- data/examples/agent/app/test.rb +7 -0
- data/examples/agent/config/Caddyfile +5 -0
- data/examples/agent/config/development.rb +5 -0
- data/examples/agent/config/production.rb +5 -0
- data/examples/agent/config/test.rb +5 -0
- data/examples/agent/docker-compose.yml +71 -0
- data/examples/agent/response_example.json +108 -0
- data/examples/agent/test/test_app.rb +14 -0
- data/examples/blog/app/foo.rb +1 -0
- data/examples/blog/config/production.rb +2 -1
- data/lib/syntropy/app.rb +6 -2
- data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
- data/lib/syntropy/dev_mode.rb +8 -3
- data/lib/syntropy/http/client.rb +17 -1
- data/lib/syntropy/http/client_connection.rb +16 -0
- data/lib/syntropy/http/io_extensions.rb +32 -4
- data/lib/syntropy/http/server.rb +55 -24
- data/lib/syntropy/http/server_connection.rb +75 -4
- data/lib/syntropy/logger.rb +8 -0
- data/lib/syntropy/markdown.rb +44 -20
- data/lib/syntropy/module_loader.rb +29 -16
- data/lib/syntropy/request/request_info.rb +1 -1
- data/lib/syntropy/request/response.rb +75 -6
- data/lib/syntropy/request/validation.rb +6 -0
- data/lib/syntropy/storage/connection_pool.rb +34 -0
- data/lib/syntropy/test.rb +4 -4
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +7 -1
- data/syntropy.gemspec +4 -4
- data/test/test_markdown.rb +3 -3
- data/test/test_module_loader.rb +4 -1
- metadata +34 -11
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// source: https://github.com/OblivionOcean/MiniGFM
|
|
2
|
+
// (MIT License)
|
|
3
|
+
/**
|
|
4
|
+
* MiniGFM - 一个简单的Markdown解析器,基本支持GFM语法。
|
|
5
|
+
* @author OblivionOcean
|
|
6
|
+
* @version 1.0.7
|
|
7
|
+
* @class
|
|
8
|
+
*/
|
|
9
|
+
export default class MiniGFM {
|
|
10
|
+
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options || {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析Markdown文本并返回HTML字符串
|
|
17
|
+
* @param {string} markdown - Markdown文本
|
|
18
|
+
* @returns {string} HTML字符串
|
|
19
|
+
*/
|
|
20
|
+
parse(markdown) {
|
|
21
|
+
if (typeof markdown != "string") return '';
|
|
22
|
+
const codeBlocks = [], codeInline = [];
|
|
23
|
+
markdown = markdown
|
|
24
|
+
// 保存原始代码块
|
|
25
|
+
.replace(/(?:^|\n)[^\\]?(`{3,4})[ ]*(\w*?)\n([\s\S]*?)\n\1/g, (_, __, lang, code) => {
|
|
26
|
+
codeBlocks.push({ lang: lang.trim(), code: code.trim() });
|
|
27
|
+
return `<!----CODEBLOCK${codeBlocks.length - 1}---->`;
|
|
28
|
+
})
|
|
29
|
+
// 保持内联代码
|
|
30
|
+
.replace(/([^\\])`([^`]+)`/g, (_, after, code) => {
|
|
31
|
+
codeInline.push(this.escapeHTML(code));
|
|
32
|
+
return `${after}<!----CODEINLINE${codeInline.length - 1}---->`;
|
|
33
|
+
})
|
|
34
|
+
// 转义特殊字符
|
|
35
|
+
.replace(/\\([\\*_{}[\]()#+\-.!`])/g, (_, m) => `&#${m.charCodeAt(0)}`)
|
|
36
|
+
// 删除注释
|
|
37
|
+
.replace(/%%[\n ][^%]+[\n ]%%/g, '');
|
|
38
|
+
|
|
39
|
+
markdown = this.parseInlines(this.parseBlocks(markdown))// 解析块和内联元素
|
|
40
|
+
// 恢复内联代码
|
|
41
|
+
.replace(/<!----CODEINLINE(\d+)---->/g, (_, id) =>
|
|
42
|
+
codeInline[id] ? `<code>${codeInline[id]}</code>` : ''
|
|
43
|
+
)
|
|
44
|
+
// 恢复代码块
|
|
45
|
+
.replace(/<!----CODEBLOCK(\d+)---->/g, (_, id) => {
|
|
46
|
+
if (!codeBlocks[id]) return '';
|
|
47
|
+
const { lang, code } = codeBlocks[id];
|
|
48
|
+
let highlighted = code;
|
|
49
|
+
|
|
50
|
+
if (this.options.hljs) try {
|
|
51
|
+
highlighted = (lang
|
|
52
|
+
? this.options.hljs.highlight(code, { language: lang })
|
|
53
|
+
: this.options.hljs.highlightAuto(code)).value;
|
|
54
|
+
} catch { }
|
|
55
|
+
|
|
56
|
+
return lang
|
|
57
|
+
? `<pre lang="${lang}"><code class="hljs ${lang} lang-${lang}">${highlighted}</code></pre>`
|
|
58
|
+
: `<pre><code>${highlighted}</code></pre>`;
|
|
59
|
+
});
|
|
60
|
+
return (!this.options.unsafe) ? this.safeHTML(markdown) : markdown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 解析跨行元素和行级块
|
|
65
|
+
* @param {string} text - 待处理的文本
|
|
66
|
+
* @returns {string} 处理后的文本
|
|
67
|
+
* @private
|
|
68
|
+
* @static
|
|
69
|
+
*/
|
|
70
|
+
parseBlocks(text) {
|
|
71
|
+
return text
|
|
72
|
+
// 标题
|
|
73
|
+
.replace(/^[^\\]?\s*(#{1,6}) ([^\n]+)$/gm, (_, level, content) => `<h${level.length}>${content}</h${level.length}>`)
|
|
74
|
+
|
|
75
|
+
// 任务列表
|
|
76
|
+
.replace(/^[ \t]*[-*+][ \t]+\[([ ]*[ xX]?)\]\s([^\n]+)$/gm, (_, checked, content) => `<li><input type="checkbox" ${checked.trim().toLowerCase() === 'x' ? 'checked' : ''} disabled> ${content}</li>`)
|
|
77
|
+
|
|
78
|
+
// 无序列表
|
|
79
|
+
.replace(/^[ \t]*[-*+] ([^\n]+)$/gm, `<li>$1</li>`)
|
|
80
|
+
|
|
81
|
+
// 有序列表
|
|
82
|
+
.replace(/^[ \t]*(\d+\.) ([^\n]+)$/gm, `<li>$1 $2</li>`)
|
|
83
|
+
|
|
84
|
+
// 分隔线
|
|
85
|
+
.replace(/^ {0,3}(([*_-])( *\2 *){2,})(?:\s*$|$)/gm, () => '<hr/>')
|
|
86
|
+
|
|
87
|
+
// 引用块
|
|
88
|
+
.replace(/^[ \t]*((?:\>[ \t]*)+)([^\n]*)$/gm, (_, sep, content) => {
|
|
89
|
+
if (content.trim() === '') return '';
|
|
90
|
+
let num = sep.length / 2;
|
|
91
|
+
return "<blockquote>".repeat(num) + content + "</blockquote>".repeat(num);
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// 表格
|
|
95
|
+
.replace(/^([^\n]*\|[^\n]*)\n([-:| ]+\|)+[-\| ]*\n((?:[^\n]*\|[^\n]*(?:\n|$))*)/gm, this.parseTable.bind(this))
|
|
96
|
+
|
|
97
|
+
// 段落处理
|
|
98
|
+
.split(/\n{2,}|\\\n/g)
|
|
99
|
+
.map(s => /^<(\w+)/.test(s) ? s : `<p>${s}</p>`)
|
|
100
|
+
.join(this.options.noMoreBr ? '' : '<br />');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 解析表格
|
|
105
|
+
* @param {*} _ 忽略参数,保持接口一致
|
|
106
|
+
* @param {Array} headers 表头
|
|
107
|
+
* @param {String} alignLine 对齐方式
|
|
108
|
+
* @param {Array} rows 表格行
|
|
109
|
+
* @return {String}
|
|
110
|
+
* @private
|
|
111
|
+
*/
|
|
112
|
+
parseTable(_, headers, alignLine, rows) {
|
|
113
|
+
// 解析表头
|
|
114
|
+
const headerCols = headers.split('|').map(h => h.trim()).filter(Boolean);
|
|
115
|
+
|
|
116
|
+
// 解析对齐方式
|
|
117
|
+
const aligns = this.parseTableAlignment(alignLine);
|
|
118
|
+
|
|
119
|
+
// 解析行数据(兼容列数不一致的情况)
|
|
120
|
+
const bodyRows = rows.trim().split('\n').reduce((arr, line) => {
|
|
121
|
+
if (!line.includes('|')) return arr;
|
|
122
|
+
const cols = line.split('|').slice(1, -1).map(c => c.trim()); // 移除首尾空列
|
|
123
|
+
arr.push(headerCols.map((_, i) => cols[i] || '')); // 按表头列数填充
|
|
124
|
+
return arr;
|
|
125
|
+
}, []);
|
|
126
|
+
const table = ['<table>', '<thead><tr>', ...headerCols.map((h, i) => `<th${aligns[i] ? ` align="${aligns[i]}"` : ''}>${h}</th>`), '</tr></thead>'];
|
|
127
|
+
if (bodyRows.length) {
|
|
128
|
+
table.push('<tbody>');
|
|
129
|
+
bodyRows.forEach(row => {
|
|
130
|
+
table.push('<tr>',
|
|
131
|
+
...row.map((c, j) => `<td${aligns[j] ? ` align="${aligns[j]}"` : ''}>${c}</td>`),
|
|
132
|
+
'</tr>');
|
|
133
|
+
});
|
|
134
|
+
table.push('</tbody>');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...table, '</table>'].join('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 解析表格对齐方式
|
|
142
|
+
* @param {string} alignLine
|
|
143
|
+
* @returns {Array}
|
|
144
|
+
* @private
|
|
145
|
+
* @static
|
|
146
|
+
*/
|
|
147
|
+
parseTableAlignment(alignLine) {
|
|
148
|
+
return alignLine.split('|').map(part => {
|
|
149
|
+
part = part.trim();
|
|
150
|
+
if (!part) return null;
|
|
151
|
+
const left = part.startsWith(':'), right = part.endsWith(':');
|
|
152
|
+
return left && right ? 'center' : left ? 'left' : right ? 'right' : null;
|
|
153
|
+
}).filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 解析内联表达式
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @return {string} 解析后的文本
|
|
160
|
+
* @private
|
|
161
|
+
* @static
|
|
162
|
+
*/
|
|
163
|
+
parseInlines(text) {
|
|
164
|
+
// 粗体
|
|
165
|
+
return text
|
|
166
|
+
.replace(/(.+)^(.+)/g, '$1<sup>$2</sup>')
|
|
167
|
+
.replace(/(.+)_(.+)/g, '$1<sub>$2</sub>')
|
|
168
|
+
.replace(/[*_]{2}(.+?)[*_]{2}/g, '<strong>$1</strong>')
|
|
169
|
+
.replace(/(?<!\*)_(.+?)_(?!\*)|(?<!\*)\*(.+?)\*(?!\*)/, (_, g1, g2) => `<em>${g1 || g2}</em>`)
|
|
170
|
+
|
|
171
|
+
// 删除线
|
|
172
|
+
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
|
173
|
+
|
|
174
|
+
// 自动链接
|
|
175
|
+
.replace(/\<([^\s@>]+@[^\s@>]+\.[^\s@>]+)\>/g, '<a href="mailto:$1">$1</a>')
|
|
176
|
+
.replace(/\<((?:https?:\/\/|ftp:\/\/|mailto:|tel:)[^>\s]+)\>/g, '<a href="$1">$1</a>')
|
|
177
|
+
|
|
178
|
+
// 图片
|
|
179
|
+
.replace(/\!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
|
|
180
|
+
|
|
181
|
+
// 链接
|
|
182
|
+
.replace(/\[([^\]]+)\]\(([^) ]+)[ ]?(\"[^)"\"]+\")?\)/g, (_, desc, url, title) => `<a href="${url}"${(title) ? " title=" + title : ""}>${desc}</a>`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 转义 HTML 字符串
|
|
187
|
+
* @param {string} text
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
escapeHTML(text) {
|
|
191
|
+
return text.replace(/[&<>"']/g, m => `&#${m.charCodeAt(0)}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 安全化 HTML 字符串
|
|
196
|
+
* @param {string} text
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
safeHTML(text) {
|
|
200
|
+
return text
|
|
201
|
+
.replace(/<(\/?)\s*(script|iframe|object|embed|frame|link|meta|style|svg|math)[^>]*>/gi, m => this.escapeHTML(m))
|
|
202
|
+
.replace(/\s(?!data-)[\w-]+=\s*["'\s]*(javascript:|data:|expression:)[^"'\s>]*/gi, '').replace(
|
|
203
|
+
/\<[^\>]+\>/g,
|
|
204
|
+
tag => tag.replace(/\s+on\w+\s*=\s*["']?[^"'\\]*["']?/gi, '')
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as Tools from './tools.js';
|
|
2
|
+
|
|
3
|
+
export function buildExchangeBody(prompt) {
|
|
4
|
+
return {
|
|
5
|
+
model: "google/gemma-4-26b-a4b-it",
|
|
6
|
+
messages: [
|
|
7
|
+
{
|
|
8
|
+
role: "user",
|
|
9
|
+
content: prompt
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
tools: Tools.spec()
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function query(ctx) {
|
|
18
|
+
const api_key = window.OPEN_ROUTER_API_KEY;
|
|
19
|
+
const response = await fetch(
|
|
20
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
21
|
+
{
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"Authorization": `Bearer ${api_key}`,
|
|
25
|
+
"Content-Type": "application/json"
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(ctx)
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
return await response.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function addMessage(ctx, message) {
|
|
34
|
+
ctx.messages.push(message);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mergeToolResults(ctx, tool_results) {
|
|
38
|
+
tool_results.forEach((r) => {
|
|
39
|
+
addMessage(ctx, {
|
|
40
|
+
role: "tool",
|
|
41
|
+
tool_call_id: r.id,
|
|
42
|
+
content: JSON.stringify(r.result)
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
* {
|
|
2
|
+
border: 0;
|
|
3
|
+
font: inherit;
|
|
4
|
+
font-size: 100%;
|
|
5
|
+
vertical-align: baseline;
|
|
6
|
+
line-height: 1.5em;
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
background-color: #f8f8f8;
|
|
10
|
+
color: #222;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
max-width: 800px;
|
|
15
|
+
margin: 4em auto;
|
|
16
|
+
|
|
17
|
+
font-size: 1.5em;
|
|
18
|
+
font-family: sans-serif;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (max-width: 768px) {
|
|
22
|
+
body {
|
|
23
|
+
margin-inline: 1em;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function spec() {
|
|
2
|
+
return [
|
|
3
|
+
{
|
|
4
|
+
type: "function",
|
|
5
|
+
function: {
|
|
6
|
+
name: "get_unit_alarms",
|
|
7
|
+
description: "Retrieve list of alarms for one or more units. If no unit names are given, returns all alarms",
|
|
8
|
+
parameters: {
|
|
9
|
+
type: "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
units: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: { type: "string" },
|
|
14
|
+
description: "List of unit names"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
required: ["units"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"type": "function",
|
|
24
|
+
"function": {
|
|
25
|
+
"name": "geo_search_units",
|
|
26
|
+
"description": "Search for units by location",
|
|
27
|
+
"parameters": {
|
|
28
|
+
type: "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
location: {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Location"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: ["location"]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function toolHandler(name, args) {
|
|
43
|
+
if (name == "get_unit_alarms") {
|
|
44
|
+
return await get_unit_alarms(args);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw new Error(`Invalid tool call: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function get_unit_alarms({ units }) {
|
|
51
|
+
const alarms = [];
|
|
52
|
+
const all = units.length == 0;
|
|
53
|
+
if (all || (units.indexOf('/icex1') >= 0)) {
|
|
54
|
+
alarms.push({
|
|
55
|
+
path: '/icex1/a1',
|
|
56
|
+
description: 'High pressure in unit 1'
|
|
57
|
+
});
|
|
58
|
+
alarms.push({
|
|
59
|
+
path: '/icex1/a2',
|
|
60
|
+
description: 'High temperature in unit 1'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (all || (units.indexOf('/icex2') >= 0)) {
|
|
64
|
+
alarms.push({
|
|
65
|
+
path: '/icex2/a3',
|
|
66
|
+
description: 'Low pressure in unit 2'
|
|
67
|
+
});
|
|
68
|
+
alarms.push({
|
|
69
|
+
path: '/icex2/a4',
|
|
70
|
+
description: 'Low temperature in unit 2'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return alarms;
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import MiniGFM from './minigfm.js'; // for UI
|
|
2
|
+
|
|
3
|
+
export function createNewSection() {
|
|
4
|
+
const main = document.querySelector("main");
|
|
5
|
+
const section = document.createElement("section");
|
|
6
|
+
main.appendChild(section);
|
|
7
|
+
return section;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function askPrompt(section) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
setupForm(section, resolve);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setupForm(section, submit_callback) {
|
|
17
|
+
const template = document.querySelector("#template-prompt-form");
|
|
18
|
+
const form = document.importNode(template.content, true);
|
|
19
|
+
section.appendChild(form);
|
|
20
|
+
section
|
|
21
|
+
.querySelector("#prompt-form")
|
|
22
|
+
.addEventListener("submit", function (e) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
submit_callback(section.querySelector("#prompt-text").value);
|
|
25
|
+
});
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
section.querySelector("#prompt-text").focus();
|
|
28
|
+
}, 50);
|
|
29
|
+
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function addSectionEntryMarkdown(section, md) {
|
|
33
|
+
const parser = new MiniGFM({});
|
|
34
|
+
const html = parser.parse(md);
|
|
35
|
+
|
|
36
|
+
const ele = document.createElement("div");
|
|
37
|
+
ele.innerHTML = html;
|
|
38
|
+
section.appendChild(ele);
|
|
39
|
+
return ele;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addPendingEntry(section) {
|
|
43
|
+
const ele = addSectionEntryMarkdown(section, "*Waiting for response...*");
|
|
44
|
+
ele.classList.add("pending");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function removePendingEntry(section) {
|
|
48
|
+
const ele = section.querySelector('.pending');
|
|
49
|
+
if (ele) ele.remove();
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
layout = import '_layout/default'
|
|
2
|
+
|
|
3
|
+
require 'papercraft/version'
|
|
4
|
+
|
|
5
|
+
export layout.apply {
|
|
6
|
+
header {
|
|
7
|
+
h1 'Fake-MCP Agent test'
|
|
8
|
+
}
|
|
9
|
+
main {
|
|
10
|
+
}
|
|
11
|
+
template(id: 'template-prompt-form') {
|
|
12
|
+
form(id: 'prompt-form') {
|
|
13
|
+
input type: 'text', id: 'prompt-text', minlength: 5, required: true, value: 'Give me all alarms'
|
|
14
|
+
button "Submit", type: 'submit', id: 'prompt-submit'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
# script(src: '/assets/minigfm.js' )
|
|
18
|
+
script(src: '/assets/agent.js', type: 'module')
|
|
19
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#version: "3.8"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
app_server:
|
|
5
|
+
build: .
|
|
6
|
+
security_opt:
|
|
7
|
+
- seccomp:unconfined
|
|
8
|
+
volumes:
|
|
9
|
+
- .:/syntropy
|
|
10
|
+
stop_signal: SIGINT
|
|
11
|
+
stop_grace_period: 10s
|
|
12
|
+
restart: unless-stopped
|
|
13
|
+
healthcheck:
|
|
14
|
+
test: "curl 'http://localhost:1234/'"
|
|
15
|
+
interval: "30s"
|
|
16
|
+
timeout: "3s"
|
|
17
|
+
start_period: "5s"
|
|
18
|
+
retries: 3
|
|
19
|
+
networks:
|
|
20
|
+
- proxy_network
|
|
21
|
+
|
|
22
|
+
console:
|
|
23
|
+
build: .
|
|
24
|
+
command: bundle exec syntropy console
|
|
25
|
+
stdin_open: true # docker run -i
|
|
26
|
+
tty: true
|
|
27
|
+
security_opt:
|
|
28
|
+
- seccomp:unconfined
|
|
29
|
+
profiles:
|
|
30
|
+
- dev
|
|
31
|
+
volumes:
|
|
32
|
+
- .:/syntropy
|
|
33
|
+
stop_signal: SIGINT
|
|
34
|
+
restart: never
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
build: .
|
|
38
|
+
command: bundle exec syntropy test -w
|
|
39
|
+
stdin_open: true # docker run -i
|
|
40
|
+
tty: true
|
|
41
|
+
security_opt:
|
|
42
|
+
- seccomp:unconfined
|
|
43
|
+
profiles:
|
|
44
|
+
- dev
|
|
45
|
+
volumes:
|
|
46
|
+
- .:/syntropy
|
|
47
|
+
stop_signal: SIGINT
|
|
48
|
+
restart: never
|
|
49
|
+
|
|
50
|
+
proxy:
|
|
51
|
+
depends_on:
|
|
52
|
+
- app_server
|
|
53
|
+
image: caddy:2-alpine
|
|
54
|
+
build:
|
|
55
|
+
context: ./proxy
|
|
56
|
+
dockerfile: Dockerfile
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
ports:
|
|
59
|
+
- "80:80"
|
|
60
|
+
- "443:443"
|
|
61
|
+
- "443:443/udp"
|
|
62
|
+
volumes:
|
|
63
|
+
- ./config/Caddyfile:/etc/caddy/Caddyfile
|
|
64
|
+
- ./storage/caddy/data:/data
|
|
65
|
+
- ./storage/caddy/config:/config
|
|
66
|
+
networks:
|
|
67
|
+
- proxy_network
|
|
68
|
+
|
|
69
|
+
networks:
|
|
70
|
+
proxy_network:
|
|
71
|
+
name: proxy_network
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "gen-1782133158-2Ap6AR8TBbCGsoP4ACkh",
|
|
3
|
+
"object": "chat.completion",
|
|
4
|
+
"created": 1782133158,
|
|
5
|
+
"model": "google/gemma-4-26b-a4b-it-20260403",
|
|
6
|
+
"provider": "DeepInfra",
|
|
7
|
+
"system_fingerprint": null,
|
|
8
|
+
"service_tier": null,
|
|
9
|
+
"choices": [
|
|
10
|
+
{
|
|
11
|
+
"index": 0,
|
|
12
|
+
"logprobs": null,
|
|
13
|
+
"finish_reason": "tool_calls",
|
|
14
|
+
"native_finish_reason": "tool_calls",
|
|
15
|
+
"message": {
|
|
16
|
+
"role": "assistant",
|
|
17
|
+
"content": null,
|
|
18
|
+
"refusal": null,
|
|
19
|
+
"reasoning": null,
|
|
20
|
+
"tool_calls": [
|
|
21
|
+
{
|
|
22
|
+
"type": "function",
|
|
23
|
+
"index": 0,
|
|
24
|
+
"id": "chatcmpl-tool-a10b45dc6cb1da7c",
|
|
25
|
+
"function": {
|
|
26
|
+
"name": "get_unit_alarms",
|
|
27
|
+
"arguments": "{\"units\": []}"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"usage": {
|
|
35
|
+
"prompt_tokens": 138,
|
|
36
|
+
"completion_tokens": 15,
|
|
37
|
+
"total_tokens": 153,
|
|
38
|
+
"cost": 0.00001476,
|
|
39
|
+
"is_byok": false,
|
|
40
|
+
"prompt_tokens_details": {
|
|
41
|
+
"cached_tokens": 0,
|
|
42
|
+
"cache_write_tokens": 0,
|
|
43
|
+
"audio_tokens": 0,
|
|
44
|
+
"video_tokens": 0
|
|
45
|
+
},
|
|
46
|
+
"cost_details": {
|
|
47
|
+
"upstream_inference_cost": 0.00001476,
|
|
48
|
+
"upstream_inference_prompt_cost": 0.00000966,
|
|
49
|
+
"upstream_inference_completions_cost": 0.0000051
|
|
50
|
+
},
|
|
51
|
+
"completion_tokens_details": {
|
|
52
|
+
"reasoning_tokens": 0,
|
|
53
|
+
"image_tokens": 0,
|
|
54
|
+
"audio_tokens": 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
"id": "gen-1782148953-LA0Rlt0gpMPn8Zoe0tuz",
|
|
65
|
+
"object": "chat.completion",
|
|
66
|
+
"created": 1782148953,
|
|
67
|
+
"model": "google/gemma-4-26b-a4b-it-20260403",
|
|
68
|
+
"provider": "DeepInfra",
|
|
69
|
+
"system_fingerprint": null,
|
|
70
|
+
"service_tier": null,
|
|
71
|
+
"choices": [
|
|
72
|
+
{
|
|
73
|
+
"index": 0,
|
|
74
|
+
"logprobs": null,
|
|
75
|
+
"finish_reason": "stop",
|
|
76
|
+
"native_finish_reason": "stop",
|
|
77
|
+
"message": {
|
|
78
|
+
"role": "assistant",
|
|
79
|
+
"content": "The current alarms are:\n- **High pressure in region 1** (Path: `/a1`)\n- **Low temperature in region 2** (Path: `/a2`)",
|
|
80
|
+
"refusal": null,
|
|
81
|
+
"reasoning": null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"usage": {
|
|
86
|
+
"prompt_tokens": 197,
|
|
87
|
+
"completion_tokens": 44,
|
|
88
|
+
"total_tokens": 241,
|
|
89
|
+
"cost": 0.00002875,
|
|
90
|
+
"is_byok": false,
|
|
91
|
+
"prompt_tokens_details": {
|
|
92
|
+
"cached_tokens": 0,
|
|
93
|
+
"cache_write_tokens": 0,
|
|
94
|
+
"audio_tokens": 0,
|
|
95
|
+
"video_tokens": 0
|
|
96
|
+
},
|
|
97
|
+
"cost_details": {
|
|
98
|
+
"upstream_inference_cost": 0.00002875,
|
|
99
|
+
"upstream_inference_prompt_cost": 0.00001379,
|
|
100
|
+
"upstream_inference_completions_cost": 0.00001496
|
|
101
|
+
},
|
|
102
|
+
"completion_tokens_details": {
|
|
103
|
+
"reasoning_tokens": 0,
|
|
104
|
+
"image_tokens": 0,
|
|
105
|
+
"audio_tokens": 0
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AppTest < Syntropy::Test
|
|
4
|
+
def test_routing
|
|
5
|
+
req = get('/')
|
|
6
|
+
assert_equal HTTP::OK, req.response_status
|
|
7
|
+
|
|
8
|
+
req = get('/test')
|
|
9
|
+
assert_equal HTTP::OK, req.response_status
|
|
10
|
+
|
|
11
|
+
req = get('/foo')
|
|
12
|
+
assert_equal HTTP::NOT_FOUND, req.response_status
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export ->(req) { req.respond('Foo') }
|