syntropy 0.38.1 → 0.40.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 +16 -0
- data/README.md +2 -0
- data/TODO.md +55 -144
- data/cmd/console.rb +2 -4
- data/cmd/new/template/config/production.rb +2 -1
- data/cmd/new.rb +1 -1
- data/cmd/serve.rb +29 -4
- data/cmd/test.rb +12 -4
- 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/app/posts/[id]/edit.rb +3 -3
- data/examples/blog/app/posts/[id]/index.rb +8 -10
- data/examples/blog/app/posts/index.rb +6 -8
- data/examples/blog/app/posts/new.rb +3 -3
- data/examples/blog/config/production.rb +2 -1
- data/lib/syntropy/app.rb +12 -60
- 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/controller_extensions.rb +1 -1
- 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 +72 -2
- data/lib/syntropy/logger.rb +8 -0
- data/lib/syntropy/markdown.rb +139 -11
- data/lib/syntropy/module_loader.rb +110 -68
- data/lib/syntropy/request/request_info.rb +8 -0
- data/lib/syntropy/request/response.rb +76 -6
- data/lib/syntropy/request/validation.rb +6 -0
- data/lib/syntropy/storage/connection_pool.rb +34 -0
- data/lib/syntropy/test.rb +14 -5
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +9 -2
- data/syntropy.gemspec +4 -4
- data/test/fixtures/app/_layout/default.rb +1 -1
- data/test/fixtures/app/_layout/kuku.rb +8 -0
- data/test/fixtures/app/_lib/circular/a.rb +2 -0
- data/test/fixtures/app/_lib/circular/b.rb +2 -0
- data/test/fixtures/app/_lib/circular/c.rb +2 -0
- data/test/fixtures/app/mod/concurrent.rb +9 -0
- data/test/test_app.rb +1 -1
- data/test/test_caching.rb +3 -3
- data/test/test_markdown.rb +268 -0
- data/test/test_module_loader.rb +32 -4
- data/test/test_request.rb +7 -0
- data/test/test_response.rb +30 -0
- data/test/test_schema.rb +1 -0
- metadata +40 -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') }
|
|
@@ -9,14 +9,14 @@ def get(req)
|
|
|
9
9
|
raise Syntropy::Error.not_found if !post
|
|
10
10
|
|
|
11
11
|
req.respond_html(
|
|
12
|
-
@template.render(post:)
|
|
12
|
+
@template.render(post:, req:)
|
|
13
13
|
)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
@template = @layout.apply { |post:, **props|
|
|
16
|
+
@template = @layout.apply { |post:, req:, **props|
|
|
17
17
|
h1 "Edit blog post"
|
|
18
18
|
div {
|
|
19
|
-
form(action: "
|
|
19
|
+
form(action: req.rel(".."), method: 'post') {
|
|
20
20
|
div {
|
|
21
21
|
label 'Title', for: 'title'
|
|
22
22
|
input name: 'title', type: 'text', value: post[:title]
|