agentgui 1.0.319 → 1.0.320
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/.prd +3 -8
- package/database.js +2 -2
- package/lib/download-metrics.js +79 -0
- package/lib/file-verification.js +26 -0
- package/lib/model-downloader.js +3 -101
- package/package.json +1 -1
- package/server.js +59 -27
- package/static/index.html +20 -0
package/.prd
CHANGED
|
@@ -44,14 +44,9 @@
|
|
|
44
44
|
|
|
45
45
|
### Wave 1 (COMPLETE - all items removed from work breakdown)
|
|
46
46
|
|
|
47
|
-
### Wave 2 (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
- Blocks: Real IPFS publishing
|
|
51
|
-
|
|
52
|
-
5. Design server.js integration strategy
|
|
53
|
-
- Blocked by: Wave 1 item 1 (implementation verification)
|
|
54
|
-
- Blocks: Actual integration
|
|
47
|
+
### Wave 2 (COMPLETE)
|
|
48
|
+
✓ Pinata API keys: BLOCKED - not available, user needs to sign up at https://www.pinata.cloud/
|
|
49
|
+
✓ Integration strategy designed - 150-200 line implementation, backward compatible, per-file iteration
|
|
55
50
|
|
|
56
51
|
### Wave 3 (Depends on Wave 2)
|
|
57
52
|
6. Execute publish-models-to-ipfs.js to get real CIDs (if keys available)
|
package/database.js
CHANGED
|
@@ -400,13 +400,13 @@ try {
|
|
|
400
400
|
console.log('[MODELS] Registered Whisper STT IPFS CID:', WHISPER_CID);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
const existingTTS = db.prepare('SELECT * FROM ipfs_cids WHERE modelName = ? AND modelType = ?').get('tts', 'voice');
|
|
403
|
+
const existingTTS = db.prepare('SELECT * FROM ipfs_cids WHERE modelName = ? AND modelType = ?').get('tts-models', 'voice');
|
|
404
404
|
if (!existingTTS) {
|
|
405
405
|
const cidId = `cid-${Date.now()}-tts`;
|
|
406
406
|
db.prepare(
|
|
407
407
|
`INSERT INTO ipfs_cids (id, cid, modelName, modelType, modelHash, gatewayUrl, cached_at, last_accessed_at)
|
|
408
408
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
409
|
-
).run(cidId, TTS_CID, 'tts', 'voice', 'sha256-verified', LIGHTHOUSE_GATEWAY, Date.now(), Date.now());
|
|
409
|
+
).run(cidId, TTS_CID, 'tts-models', 'voice', 'sha256-verified', LIGHTHOUSE_GATEWAY, Date.now(), Date.now());
|
|
410
410
|
console.log('[MODELS] Registered TTS IPFS CID:', TTS_CID);
|
|
411
411
|
}
|
|
412
412
|
} catch (err) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const METRICS_PATH = path.join(os.homedir(), '.gmgui', 'models', '.metrics.json');
|
|
6
|
+
|
|
7
|
+
export function recordMetric(metric) {
|
|
8
|
+
const metricsDir = path.dirname(METRICS_PATH);
|
|
9
|
+
if (!fs.existsSync(metricsDir)) {
|
|
10
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let metrics = [];
|
|
14
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
15
|
+
try {
|
|
16
|
+
metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
metrics = [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
metrics.push({
|
|
23
|
+
...metric,
|
|
24
|
+
timestamp: new Date().toISOString()
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
28
|
+
metrics = metrics.filter(m => new Date(m.timestamp).getTime() > oneDayAgo);
|
|
29
|
+
|
|
30
|
+
fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getMetrics() {
|
|
34
|
+
if (!fs.existsSync(METRICS_PATH)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getMetricsSummary() {
|
|
41
|
+
const metrics = getMetrics();
|
|
42
|
+
|
|
43
|
+
const summary = {
|
|
44
|
+
total: metrics.length,
|
|
45
|
+
cache_hits: metrics.filter(m => m.layer === 'cache' && m.status === 'hit').length,
|
|
46
|
+
ipfs: {
|
|
47
|
+
success: metrics.filter(m => m.layer === 'ipfs' && m.status === 'success').length,
|
|
48
|
+
error: metrics.filter(m => m.layer === 'ipfs' && m.status === 'error').length,
|
|
49
|
+
avg_latency: 0
|
|
50
|
+
},
|
|
51
|
+
huggingface: {
|
|
52
|
+
success: metrics.filter(m => m.layer === 'huggingface' && m.status === 'success').length,
|
|
53
|
+
error: metrics.filter(m => m.layer === 'huggingface' && m.status === 'error').length,
|
|
54
|
+
avg_latency: 0
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ipfsSuccess = metrics.filter(m => m.layer === 'ipfs' && m.status === 'success');
|
|
59
|
+
if (ipfsSuccess.length > 0) {
|
|
60
|
+
summary.ipfs.avg_latency = Math.round(
|
|
61
|
+
ipfsSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / ipfsSuccess.length
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hfSuccess = metrics.filter(m => m.layer === 'huggingface' && m.status === 'success');
|
|
66
|
+
if (hfSuccess.length > 0) {
|
|
67
|
+
summary.huggingface.avg_latency = Math.round(
|
|
68
|
+
hfSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / hfSuccess.length
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resetMetrics() {
|
|
76
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
77
|
+
fs.unlinkSync(METRICS_PATH);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
export function verifyFileIntegrity(filepath, expectedHash, minBytes) {
|
|
5
|
+
if (!fs.existsSync(filepath)) {
|
|
6
|
+
return { valid: false, reason: 'file_not_found' };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const stats = fs.statSync(filepath);
|
|
10
|
+
if (minBytes && stats.size < minBytes) {
|
|
11
|
+
return { valid: false, reason: 'size_too_small', actual: stats.size, expected: minBytes };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (expectedHash) {
|
|
15
|
+
const hash = crypto.createHash('sha256');
|
|
16
|
+
const data = fs.readFileSync(filepath);
|
|
17
|
+
hash.update(data);
|
|
18
|
+
const actualHash = hash.digest('hex');
|
|
19
|
+
|
|
20
|
+
if (actualHash !== expectedHash) {
|
|
21
|
+
return { valid: false, reason: 'hash_mismatch', actual: actualHash, expected: expectedHash };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
package/lib/model-downloader.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import { recordMetric } from './download-metrics.js';
|
|
5
|
+
import { verifyFileIntegrity } from './file-verification.js';
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
|
|
@@ -13,58 +13,6 @@ const GATEWAYS = [
|
|
|
13
13
|
'https://ipfs.io/ipfs/'
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
const METRICS_PATH = path.join(os.homedir(), '.gmgui', 'models', '.metrics.json');
|
|
17
|
-
|
|
18
|
-
function recordMetric(metric) {
|
|
19
|
-
const metricsDir = path.dirname(METRICS_PATH);
|
|
20
|
-
if (!fs.existsSync(metricsDir)) {
|
|
21
|
-
fs.mkdirSync(metricsDir, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let metrics = [];
|
|
25
|
-
if (fs.existsSync(METRICS_PATH)) {
|
|
26
|
-
try {
|
|
27
|
-
metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
28
|
-
} catch (e) {
|
|
29
|
-
metrics = [];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
metrics.push({
|
|
34
|
-
...metric,
|
|
35
|
-
timestamp: new Date().toISOString()
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
39
|
-
metrics = metrics.filter(m => new Date(m.timestamp).getTime() > oneDayAgo);
|
|
40
|
-
|
|
41
|
-
fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function verifyFileIntegrity(filepath, expectedHash, minBytes) {
|
|
45
|
-
if (!fs.existsSync(filepath)) {
|
|
46
|
-
return { valid: false, reason: 'file_not_found' };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const stats = fs.statSync(filepath);
|
|
50
|
-
if (minBytes && stats.size < minBytes) {
|
|
51
|
-
return { valid: false, reason: 'size_too_small', actual: stats.size, expected: minBytes };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (expectedHash) {
|
|
55
|
-
const hash = crypto.createHash('sha256');
|
|
56
|
-
const data = fs.readFileSync(filepath);
|
|
57
|
-
hash.update(data);
|
|
58
|
-
const actualHash = hash.digest('hex');
|
|
59
|
-
|
|
60
|
-
if (actualHash !== expectedHash) {
|
|
61
|
-
return { valid: false, reason: 'hash_mismatch', actual: actualHash, expected: expectedHash };
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return { valid: true };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
16
|
async function downloadFromIPFS(cid, destPath, manifest, onProgress) {
|
|
69
17
|
const startTime = Date.now();
|
|
70
18
|
|
|
@@ -239,50 +187,4 @@ export async function downloadWithFallback(options, onProgress) {
|
|
|
239
187
|
throw new Error('All download layers exhausted');
|
|
240
188
|
}
|
|
241
189
|
|
|
242
|
-
export
|
|
243
|
-
if (!fs.existsSync(METRICS_PATH)) {
|
|
244
|
-
return [];
|
|
245
|
-
}
|
|
246
|
-
return JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function getMetricsSummary() {
|
|
250
|
-
const metrics = getMetrics();
|
|
251
|
-
|
|
252
|
-
const summary = {
|
|
253
|
-
total: metrics.length,
|
|
254
|
-
cache_hits: metrics.filter(m => m.layer === 'cache' && m.status === 'hit').length,
|
|
255
|
-
ipfs: {
|
|
256
|
-
success: metrics.filter(m => m.layer === 'ipfs' && m.status === 'success').length,
|
|
257
|
-
error: metrics.filter(m => m.layer === 'ipfs' && m.status === 'error').length,
|
|
258
|
-
avg_latency: 0
|
|
259
|
-
},
|
|
260
|
-
huggingface: {
|
|
261
|
-
success: metrics.filter(m => m.layer === 'huggingface' && m.status === 'success').length,
|
|
262
|
-
error: metrics.filter(m => m.layer === 'huggingface' && m.status === 'error').length,
|
|
263
|
-
avg_latency: 0
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const ipfsSuccess = metrics.filter(m => m.layer === 'ipfs' && m.status === 'success');
|
|
268
|
-
if (ipfsSuccess.length > 0) {
|
|
269
|
-
summary.ipfs.avg_latency = Math.round(
|
|
270
|
-
ipfsSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / ipfsSuccess.length
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const hfSuccess = metrics.filter(m => m.layer === 'huggingface' && m.status === 'success');
|
|
275
|
-
if (hfSuccess.length > 0) {
|
|
276
|
-
summary.huggingface.avg_latency = Math.round(
|
|
277
|
-
hfSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / hfSuccess.length
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return summary;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export function resetMetrics() {
|
|
285
|
-
if (fs.existsSync(METRICS_PATH)) {
|
|
286
|
-
fs.unlinkSync(METRICS_PATH);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
190
|
+
export { getMetrics, getMetricsSummary, resetMetrics } from './download-metrics.js';
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import Busboy from 'busboy';
|
|
|
14
14
|
import fsbrowse from 'fsbrowse';
|
|
15
15
|
import { queries } from './database.js';
|
|
16
16
|
import { runClaudeWithStreaming } from './lib/claude-runner.js';
|
|
17
|
+
import { downloadWithFallback } from './lib/model-downloader.js';
|
|
17
18
|
const { downloadWithProgress } = createRequire(import.meta.url)('webtalk/ipfs-downloader');
|
|
18
19
|
|
|
19
20
|
const ttsTextAccumulators = new Map();
|
|
@@ -77,39 +78,70 @@ async function ensureModelsDownloaded() {
|
|
|
77
78
|
? (fs.existsSync(path.join(process.env.PORTABLE_EXE_DIR, 'models', 'onnx-community')) ? path.join(process.env.PORTABLE_EXE_DIR, 'models') : gmguiModels)
|
|
78
79
|
: gmguiModels;
|
|
79
80
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
ttsModelsDir: path.join(modelsBase, 'tts'),
|
|
85
|
-
sttModelsDir: path.join(modelsBase, 'stt'),
|
|
86
|
-
});
|
|
81
|
+
const manifestPath = path.join(gmguiModels, '.manifests.json');
|
|
82
|
+
if (!fs.existsSync(manifestPath)) {
|
|
83
|
+
throw new Error('Model manifest not found at ' + manifestPath);
|
|
84
|
+
}
|
|
87
85
|
|
|
88
|
-
const
|
|
89
|
-
const { checkWhisperModelExists } = createRequire(import.meta.url)('webtalk/whisper-models');
|
|
86
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
90
87
|
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
if (sttOk && ttsOk) {
|
|
95
|
-
console.log('[MODELS] All model files present');
|
|
96
|
-
modelDownloadState.complete = true;
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
88
|
+
const whisperCidRecord = queries.getIpfsCidByModel('whisper-base', 'stt');
|
|
89
|
+
const ttsCidRecord = queries.getIpfsCidByModel('tts', 'voice');
|
|
99
90
|
|
|
100
91
|
modelDownloadState.downloading = true;
|
|
101
92
|
modelDownloadState.error = null;
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
94
|
+
const downloadModel = async (modelName, modelType, cidRecord) => {
|
|
95
|
+
const modelManifest = manifest[modelName];
|
|
96
|
+
if (!modelManifest) {
|
|
97
|
+
throw new Error(`Model ${modelName} not found in manifest`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const isWhisper = modelType === 'stt';
|
|
101
|
+
const baseDir = isWhisper
|
|
102
|
+
? path.join(modelsBase, 'onnx-community', 'whisper-base')
|
|
103
|
+
: path.join(modelsBase, 'tts');
|
|
104
|
+
|
|
105
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
for (const [filename, fileInfo] of Object.entries(modelManifest.files)) {
|
|
108
|
+
const destPath = path.join(baseDir, filename);
|
|
109
|
+
|
|
110
|
+
if (fs.existsSync(destPath) && fs.statSync(destPath).size === fileInfo.size) {
|
|
111
|
+
console.log(`[MODELS] ${filename} already exists, skipping`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ipfsCid = cidRecord ? `${cidRecord.cid}/${filename}` : null;
|
|
116
|
+
const huggingfaceUrl = isWhisper
|
|
117
|
+
? `https://huggingface.co/onnx-community/whisper-base/resolve/main/${filename}`
|
|
118
|
+
: `https://huggingface.co/datasets/AnEntrypoint/sttttsmodels/resolve/main/tts/${filename}`;
|
|
119
|
+
|
|
120
|
+
await downloadWithFallback({
|
|
121
|
+
ipfsCid,
|
|
122
|
+
huggingfaceUrl,
|
|
123
|
+
destPath,
|
|
124
|
+
manifest: fileInfo,
|
|
125
|
+
minBytes: fileInfo.size * 0.8,
|
|
126
|
+
preferredLayer: ipfsCid ? 'ipfs' : 'huggingface'
|
|
127
|
+
}, (progress) => {
|
|
128
|
+
broadcastModelProgress({
|
|
129
|
+
started: true,
|
|
130
|
+
done: progress.status === 'success',
|
|
131
|
+
downloading: progress.status === 'downloading',
|
|
132
|
+
type: modelType === 'stt' ? 'whisper' : 'tts',
|
|
133
|
+
source: progress.layer === 'cache' ? 'cache' : progress.layer,
|
|
134
|
+
status: progress.status,
|
|
135
|
+
file: filename,
|
|
136
|
+
progress: progress.total ? (progress.downloaded / progress.total * 100) : 0,
|
|
137
|
+
gateway: progress.gateway
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await downloadModel('whisper-base', 'stt', whisperCidRecord);
|
|
144
|
+
await downloadModel('tts-models', 'voice', ttsCidRecord);
|
|
113
145
|
|
|
114
146
|
modelDownloadState.complete = true;
|
|
115
147
|
broadcastModelProgress({ started: true, done: true, downloading: false });
|
package/static/index.html
CHANGED
|
@@ -727,6 +727,26 @@
|
|
|
727
727
|
::-webkit-scrollbar-thumb { background-color: var(--color-border); border-radius: 3px; }
|
|
728
728
|
::-webkit-scrollbar-thumb:hover { background-color: var(--color-text-tertiary); }
|
|
729
729
|
|
|
730
|
+
/* DIALOGS */
|
|
731
|
+
.dialog-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
732
|
+
.dialog-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
|
733
|
+
.dialog-box { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; padding: 24px; min-width: 320px; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); }
|
|
734
|
+
.dialog-box h3, .dialog-box .dialog-title { font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-primary); }
|
|
735
|
+
.dialog-box p, .dialog-box .dialog-message { color: var(--color-text-secondary); margin-bottom: 16px; font-size: 0.875rem; }
|
|
736
|
+
.dialog-box input, .dialog-box textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-bg-tertiary); color: var(--color-text-primary); font-size: 0.875rem; margin-bottom: 16px; }
|
|
737
|
+
.dialog-footer { display: flex; gap: 8px; justify-content: flex-end; }
|
|
738
|
+
.dialog-btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--color-border); background: var(--color-bg-tertiary); color: var(--color-text-primary); cursor: pointer; font-size: 0.875rem; }
|
|
739
|
+
.dialog-btn-primary { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
|
740
|
+
.dialog-btn:hover { opacity: 0.85; }
|
|
741
|
+
.dialog-box-progress .dialog-progress-bar { height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden; margin-bottom: 8px; }
|
|
742
|
+
.dialog-box-progress .dialog-progress-fill { height: 100%; background: var(--color-primary); transition: width 0.3s; }
|
|
743
|
+
|
|
744
|
+
/* TOOL ICONS */
|
|
745
|
+
.folded-tool-icon { display: inline-flex; align-items: center; flex-shrink: 0; }
|
|
746
|
+
.folded-tool-icon svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
747
|
+
.folded-tool-bar { display: flex; align-items: center; gap: 6px; }
|
|
748
|
+
.tool-result-status { display: flex; align-items: center; gap: 6px; }
|
|
749
|
+
|
|
730
750
|
/* RESPONSIVE */
|
|
731
751
|
@media (max-width: 768px) {
|
|
732
752
|
.sidebar {
|