@oddessentials/agent-guild 0.3.0 → 0.4.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.
- package/package.json +1 -1
- package/src/manager/main.mjs +3 -0
- package/src/manager/news.mjs +470 -0
- package/src/manager/server.mjs +5 -0
- package/web/app.js +312 -0
- package/web/art/art.css +20 -0
- package/web/index.html +26 -0
- package/web/styles.css +58 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/main.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { ProviderRegistry } from './providers.mjs';
|
|
|
8
8
|
import { SessionManager } from './session-manager.mjs';
|
|
9
9
|
import { UsageMonitor } from './usage.mjs';
|
|
10
10
|
import { ModelStats } from './model-stats.mjs';
|
|
11
|
+
import { NewsFeed } from './news.mjs';
|
|
11
12
|
import { createManagerServer } from './server.mjs';
|
|
12
13
|
import { SelfUpdate } from './self-update.mjs';
|
|
13
14
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
@@ -59,6 +60,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
59
60
|
const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
|
|
60
61
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
61
62
|
const modelStats = new ModelStats({ registry });
|
|
63
|
+
const news = new NewsFeed({ registry });
|
|
62
64
|
let closing = null;
|
|
63
65
|
|
|
64
66
|
const refreshVersions = () => {
|
|
@@ -91,6 +93,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
91
93
|
registry,
|
|
92
94
|
usage,
|
|
93
95
|
modelStats,
|
|
96
|
+
news,
|
|
94
97
|
token,
|
|
95
98
|
host,
|
|
96
99
|
port,
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { VERSION } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
7
|
+
const RELEASE_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
9
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const CONCURRENCY = 4;
|
|
11
|
+
const WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const FUTURE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const LIMITS = { news: 20, releases: 5, research: 10 };
|
|
14
|
+
const TITLE_MAX = 300;
|
|
15
|
+
const SUMMARY_MAX = 240;
|
|
16
|
+
const RAW_MAX = 8000;
|
|
17
|
+
const USER_AGENT = `agent-guild/${VERSION} (+https://github.com/oddessentials/agent-guild)`;
|
|
18
|
+
const FEED_ACCEPT = 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5';
|
|
19
|
+
|
|
20
|
+
const latestRelease = (id, name, repo, provider) => ({
|
|
21
|
+
id, name, category: 'releases', provider, format: 'github', every: RELEASE_TTL_MS,
|
|
22
|
+
url: `https://api.github.com/repos/${repo}/releases/latest`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const FEEDS = [
|
|
26
|
+
{ id: 'openai', name: 'OpenAI', category: 'news', url: 'https://openai.com/news/rss.xml' },
|
|
27
|
+
{ id: 'google-deepmind', name: 'Google DeepMind', category: 'news', url: 'https://deepmind.google/blog/rss.xml' },
|
|
28
|
+
{ id: 'google-ai', name: 'Google AI', category: 'news', url: 'https://blog.google/innovation-and-ai/technology/ai/rss/' },
|
|
29
|
+
{ id: 'hugging-face', name: 'Hugging Face', category: 'news', url: 'https://huggingface.co/blog/feed.xml' },
|
|
30
|
+
{ id: 'ollama-blog', name: 'Ollama', category: 'news', url: 'https://ollama.com/blog/rss.xml' },
|
|
31
|
+
latestRelease('claude-code', 'Claude Code', 'anthropics/claude-code', 'anthropic'),
|
|
32
|
+
latestRelease('codex-cli', 'Codex CLI', 'openai/codex', 'openai'),
|
|
33
|
+
latestRelease('gemini-cli', 'Gemini CLI', 'google-gemini/gemini-cli', 'google'),
|
|
34
|
+
{ id: 'ollama', name: 'Ollama', category: 'releases', url: 'https://github.com/ollama/ollama/releases.atom' },
|
|
35
|
+
{ id: 'llama-cpp', name: 'llama.cpp', category: 'releases', url: 'https://github.com/ggml-org/llama.cpp/releases.atom' },
|
|
36
|
+
{ id: 'transformers', name: 'Transformers', category: 'releases', url: 'https://github.com/huggingface/transformers/releases.atom' },
|
|
37
|
+
{ id: 'arxiv-cs-cl', name: 'arXiv cs.CL', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.CL' },
|
|
38
|
+
{ id: 'arxiv-cs-ai', name: 'arXiv cs.AI', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.AI' },
|
|
39
|
+
{ id: 'simon-willison', name: 'Simon Willison\'s Weblog', category: 'news', url: 'https://simonwillison.net/atom/everything/' },
|
|
40
|
+
{ id: 'interconnects', name: 'Interconnects', category: 'news', url: 'https://www.interconnects.ai/feed' },
|
|
41
|
+
{ id: 'lil-log', name: 'Lil\'Log', category: 'news', url: 'https://lilianweng.github.io/index.xml' },
|
|
42
|
+
{
|
|
43
|
+
id: 'hacker-news', name: 'Hacker News', category: 'news', format: 'hn', filter: true,
|
|
44
|
+
url: 'https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points%3E%3D50&hitsPerPage=100&attributesToRetrieve=title,url,created_at,points,num_comments,objectID&attributesToHighlight=none',
|
|
45
|
+
},
|
|
46
|
+
{ id: 'llm-digest', name: 'LLM Digest', category: 'news', url: 'https://www.llm-digest.com/rss.xml' },
|
|
47
|
+
{ id: 'slashdot', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotMain' },
|
|
48
|
+
{ id: 'slashdot-developers', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotDevelopers' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const TERMS = /\bagents?\b|\bagentic\b|\bmulti[-\s]?agents?\b|\btool[-\s]?use\b|\bmcp\b|\bollama\b|\bllama\.cpp\b|\bllamacpp\b|\bggml\b|\bgguf\b|\bopen[-\s]?weights?\b|\blocal[-\s]+models?\b|\bvllm\b|\bsglang\b|\bquanti[sz]\w*\b/i;
|
|
52
|
+
const TRACKING = new Set(['fbclid', 'gclid', 'mc_cid', 'mc_eid', 'igshid', 'ref_src']);
|
|
53
|
+
const ARXIV_PATH = /^\/(?:abs|pdf|html)\/([a-z-]+\/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?(?:\.pdf)?$/i;
|
|
54
|
+
const PRERELEASE = /(?:^|[^a-z])(?:alpha|beta|rc|nightly|preview|canary|dev|pre)(?![a-z])/i;
|
|
55
|
+
const ARXIV_PREFIX = /^arXiv:\S+\s+Announce Type:\s*[\w-]+\s+Abstract:\s*/i;
|
|
56
|
+
const ENTRY = /<((?:[\w-]+:)?(?:item|entry))\b[^>]*>([\s\S]*?)<\/\1\s*>/gi;
|
|
57
|
+
const DATE_FIELDS = ['pubDate', 'published', 'dc:date', 'updated', 'issued', 'modified'];
|
|
58
|
+
const NAMED = {
|
|
59
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', shy: '', zwj: '', zwnj: '',
|
|
60
|
+
ndash: '–', mdash: '—', hellip: '…', lsquo: '‘', rsquo: '’', sbquo: '‚', ldquo: '“', rdquo: '”', bdquo: '„', laquo: '«', raquo: '»',
|
|
61
|
+
bull: '•', middot: '·', prime: '′', Prime: '″', copy: '©', reg: '®', trade: '™', deg: '°', plusmn: '±', times: '×', divide: '÷',
|
|
62
|
+
euro: '€', pound: '£', cent: '¢', yen: '¥', sect: '§', para: '¶', larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔',
|
|
63
|
+
aacute: 'á', agrave: 'à', acirc: 'â', auml: 'ä', eacute: 'é', egrave: 'è', ecirc: 'ê', iacute: 'í', oacute: 'ó', ouml: 'ö',
|
|
64
|
+
uacute: 'ú', uuml: 'ü', ccedil: 'ç', ntilde: 'ñ', szlig: 'ß', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
class FeedError extends Error {}
|
|
68
|
+
|
|
69
|
+
const fields = new Map();
|
|
70
|
+
const attrs = new Map();
|
|
71
|
+
|
|
72
|
+
export function matchesTerms(title, text = '') {
|
|
73
|
+
return TERMS.test(`${title}\n${text}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodeEntities(text) {
|
|
77
|
+
return text.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z][a-z0-9]{1,31}));/gi, (match, dec, hex, name) => {
|
|
78
|
+
if (name) return Object.hasOwn(NAMED, name) ? NAMED[name] : match;
|
|
79
|
+
const code = dec ? Number(dec) : parseInt(hex, 16);
|
|
80
|
+
return code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff) ? String.fromCodePoint(code) : '�';
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tidy(text) {
|
|
85
|
+
return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stripTags(html) {
|
|
89
|
+
return html
|
|
90
|
+
.replace(/<(script|style|template)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ')
|
|
91
|
+
.replace(/<\/?(?:p|div|br|li|ul|ol|h[1-6]|tr|td|th|table|blockquote|pre|section|article|header|footer|figure|figcaption|hr)\b[^>]*>/gi, ' ')
|
|
92
|
+
.replace(/<[^>]*>/g, '')
|
|
93
|
+
.replace(/<[^>]*$/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function clip(text, max) {
|
|
97
|
+
if (text.length <= max) return text;
|
|
98
|
+
let cut = text.slice(0, max);
|
|
99
|
+
if (/[\ud800-\udbff]$/.test(cut)) cut = cut.slice(0, -1);
|
|
100
|
+
const space = cut.lastIndexOf(' ');
|
|
101
|
+
if (space >= max * 0.6) cut = cut.slice(0, space);
|
|
102
|
+
return `${cut.replace(/[\s.,;:–—-]+$/, '')}…`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function protect(xml) {
|
|
106
|
+
const blocks = [];
|
|
107
|
+
const text = xml
|
|
108
|
+
.replace(/\u0000/g, '')
|
|
109
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, inner) => `\u0000${blocks.push(inner) - 1}\u0000`)
|
|
110
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
111
|
+
return { text, blocks };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function xmlText(inner, blocks) {
|
|
115
|
+
return inner.split(/\u0000(\d+)\u0000/).map((part, i) => (i % 2 ? blocks[Number(part)] ?? '' : decodeEntities(part))).join('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function field(raw, name) {
|
|
119
|
+
let re = fields.get(name);
|
|
120
|
+
if (!re) fields.set(name, (re = new RegExp(`<${name}(\\s[^>]*)?(?:/>|>([\\s\\S]*?)</${name}\\s*>)`, 'i')));
|
|
121
|
+
const match = re.exec(raw);
|
|
122
|
+
return match ? { attrs: match[1] ?? '', inner: match[2] ?? '' } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function attr(source, name) {
|
|
126
|
+
let re = attrs.get(name);
|
|
127
|
+
if (!re) attrs.set(name, (re = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')));
|
|
128
|
+
const match = re.exec(source);
|
|
129
|
+
return match ? decodeEntities(match[1] ?? match[2]) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function textOf(found, blocks, { html, max = RAW_MAX }) {
|
|
133
|
+
const type = attr(found.attrs, 'type');
|
|
134
|
+
const source = xmlText(found.inner.slice(0, max), blocks).slice(0, max);
|
|
135
|
+
return tidy(decodeEntities((type ? /html/i.test(type) : html) ? stripTags(source) : source));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function webUrl(value, base) {
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(String(value).trim(), base);
|
|
141
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.href.length > 2000) return null;
|
|
142
|
+
return url.href;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function entryLink(raw, blocks, base) {
|
|
149
|
+
const links = [...raw.matchAll(/<link\b([^>]*)>/gi)]
|
|
150
|
+
.map(([, found]) => ({ href: attr(found, 'href'), rel: attr(found, 'rel') ?? 'alternate', type: attr(found, 'type') }))
|
|
151
|
+
.filter((link) => link.href);
|
|
152
|
+
const alternate = links.find((link) => link.rel === 'alternate' && (!link.type || /html/i.test(link.type))) ?? links.find((link) => link.rel === 'alternate');
|
|
153
|
+
if (alternate) return webUrl(alternate.href, base);
|
|
154
|
+
const text = field(raw, 'link');
|
|
155
|
+
if (text && text.inner.trim()) return webUrl(tidy(xmlText(text.inner, blocks)), base);
|
|
156
|
+
const guid = field(raw, 'guid');
|
|
157
|
+
return guid && attr(guid.attrs, 'isPermaLink') !== 'false' ? webUrl(tidy(xmlText(guid.inner, blocks)), base) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function entryDate(raw, blocks) {
|
|
161
|
+
for (const name of DATE_FIELDS) {
|
|
162
|
+
const found = field(raw, name);
|
|
163
|
+
if (!found) continue;
|
|
164
|
+
const time = Date.parse(tidy(xmlText(found.inner, blocks)));
|
|
165
|
+
if (Number.isFinite(time)) return time;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function feedEntries(xml) {
|
|
171
|
+
const { text, blocks } = protect(xml);
|
|
172
|
+
if (!/<(?:[\w-]+:)?(?:rss|feed|RDF)\b/i.test(text)) throw new FeedError('did not send a feed');
|
|
173
|
+
return [...text.matchAll(ENTRY)].map(([, , raw]) => ({ raw, blocks, date: entryDate(raw, blocks) }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readEntry({ raw, blocks }, base) {
|
|
177
|
+
const title = field(raw, 'title');
|
|
178
|
+
const body = [['summary', false], ['description', true], ['content:encoded', true], ['content', false]]
|
|
179
|
+
.map(([name, html]) => [field(raw, name), html])
|
|
180
|
+
.find(([found]) => found);
|
|
181
|
+
const announce = field(raw, 'arxiv:announce_type');
|
|
182
|
+
return {
|
|
183
|
+
title: title ? clip(textOf(title, blocks, { html: false, max: Infinity }), TITLE_MAX) : '',
|
|
184
|
+
link: entryLink(raw, blocks, base),
|
|
185
|
+
text: body ? textOf(body[0], blocks, { html: body[1] }).replace(ARXIV_PREFIX, '') : '',
|
|
186
|
+
announce: announce ? tidy(xmlText(announce.inner, blocks)) : null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function parseFeed(xml, base) {
|
|
191
|
+
return feedEntries(xml).map((entry) => ({ ...readEntry(entry, base), date: entry.date }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function count(value, word) {
|
|
195
|
+
return Number.isInteger(value) && value >= 0 ? `${value} ${word}${value === 1 ? '' : 's'}` : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function parseHackerNews(text) {
|
|
199
|
+
const hits = JSON.parse(text)?.hits;
|
|
200
|
+
if (!Array.isArray(hits)) throw new FeedError('sent no stories');
|
|
201
|
+
return hits.filter((hit) => hit && typeof hit === 'object').map((hit) => {
|
|
202
|
+
const id = String(hit.objectID ?? '');
|
|
203
|
+
const thread = /^\d+$/.test(id) ? `https://news.ycombinator.com/item?id=${id}` : null;
|
|
204
|
+
const link = (typeof hit.url === 'string' && webUrl(hit.url)) || thread;
|
|
205
|
+
return {
|
|
206
|
+
title: clip(tidy(decodeEntities(String(hit.title ?? ''))), TITLE_MAX),
|
|
207
|
+
link,
|
|
208
|
+
text: [count(hit.points, 'point'), count(hit.num_comments, 'comment')].filter(Boolean).join(' · '),
|
|
209
|
+
discussion: link === thread ? null : thread,
|
|
210
|
+
date: Date.parse(hit.created_at),
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function markdownText(markdown) {
|
|
216
|
+
return String(markdown)
|
|
217
|
+
.replace(/```[\s\S]*?(?:```|$)/g, ' ')
|
|
218
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
219
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
220
|
+
.replace(/<[^>]*>/g, ' ')
|
|
221
|
+
.split(/\r?\n/)
|
|
222
|
+
.filter((line) => !/^\s*(?:#{1,6}\s|\*\*full changelog\b|full changelog\b)/i.test(line))
|
|
223
|
+
.map((line) => tidy(decodeEntities(line
|
|
224
|
+
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, '')
|
|
225
|
+
.replace(/^#\d+\s+|\s*\(#\d+\)/g, '')
|
|
226
|
+
.replace(/\s+by @[\w-]+(?:\s+in\s+\S+)?/g, '')
|
|
227
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
228
|
+
.replace(/[*_`~]+/g, ''))))
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join(' · ');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function releaseTitle(name, title) {
|
|
234
|
+
const label = title.replace(/^(?:patch\s+)?release\b:?\s*/i, '').replace(/^rust-(?=v?\d)/i, '').trim() || title;
|
|
235
|
+
return label.toLowerCase().startsWith(name.toLowerCase()) ? label : `${name} ${label}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseGithubRelease(text, name) {
|
|
239
|
+
const release = JSON.parse(text);
|
|
240
|
+
if (!release || typeof release !== 'object' || typeof release.tag_name !== 'string') throw new FeedError('sent no release');
|
|
241
|
+
if (release.draft || release.prerelease) return [];
|
|
242
|
+
return [{
|
|
243
|
+
title: releaseTitle(name, tidy(String(release.name || release.tag_name))),
|
|
244
|
+
link: webUrl(release.html_url ?? ''),
|
|
245
|
+
text: markdownText(release.body ?? ''),
|
|
246
|
+
date: Date.parse(release.published_at),
|
|
247
|
+
}];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function releaseTag(link) {
|
|
251
|
+
const tag = /^https?:\/\/(?:www\.)?github\.com\/[^/]+\/[^/]+\/releases\/tag\/([^/?#]+)/i.exec(link)?.[1];
|
|
252
|
+
if (!tag) return null;
|
|
253
|
+
try {
|
|
254
|
+
return decodeURIComponent(tag);
|
|
255
|
+
} catch {
|
|
256
|
+
return tag;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function isPrerelease(tag) {
|
|
261
|
+
return PRERELEASE.test(tag);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const tracking = (key) => /^utm_/i.test(key) || TRACKING.has(key.toLowerCase());
|
|
265
|
+
|
|
266
|
+
export function cleanUrl(link) {
|
|
267
|
+
const url = new URL(link);
|
|
268
|
+
const keys = [...url.searchParams.keys()].filter(tracking);
|
|
269
|
+
if (keys.length === 0) return link;
|
|
270
|
+
for (const key of keys) url.searchParams.delete(key);
|
|
271
|
+
return url.href;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function canonicalUrl(link) {
|
|
275
|
+
const url = new URL(link);
|
|
276
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
277
|
+
const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : '';
|
|
278
|
+
let path = url.pathname || '/';
|
|
279
|
+
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
|
|
280
|
+
const arxiv = host.endsWith('arxiv.org') ? ARXIV_PATH.exec(path) : null;
|
|
281
|
+
if (arxiv) path = `/abs/${arxiv[1]}`;
|
|
282
|
+
const query = [...url.searchParams]
|
|
283
|
+
.filter(([key]) => !tracking(key))
|
|
284
|
+
.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
|
|
285
|
+
return `${host}${port}${path}${query.length ? `?${new URLSearchParams(query)}` : ''}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function summaryFor(text, title) {
|
|
289
|
+
const rest = text.toLowerCase().startsWith(title.toLowerCase()) ? text.slice(title.length).replace(/^[\s.,;:–—-]+/, '') : text;
|
|
290
|
+
return clip(rest, SUMMARY_MAX);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function select(feed, entries, now) {
|
|
294
|
+
const fresh = entries
|
|
295
|
+
.filter((entry) => Number.isFinite(entry.date) && entry.date - now <= FUTURE_MS && now - entry.date <= WINDOW_MS)
|
|
296
|
+
.sort((a, b) => b.date - a.date);
|
|
297
|
+
const items = [];
|
|
298
|
+
for (const entry of fresh) {
|
|
299
|
+
if (items.length >= LIMITS[feed.category]) break;
|
|
300
|
+
const found = entry.raw === undefined ? entry : { ...readEntry(entry, feed.url), date: entry.date };
|
|
301
|
+
if (!found.title || !found.link) continue;
|
|
302
|
+
if (found.announce && /^replace/i.test(found.announce)) continue;
|
|
303
|
+
const release = feed.category === 'releases' && feed.format !== 'github';
|
|
304
|
+
const tag = releaseTag(found.link) ?? (release ? found.title.split(' ')[0] : null);
|
|
305
|
+
if (tag && isPrerelease(tag)) continue;
|
|
306
|
+
if (feed.filter && !matchesTerms(found.title, found.text)) continue;
|
|
307
|
+
const key = canonicalUrl(found.link);
|
|
308
|
+
items.push({
|
|
309
|
+
key,
|
|
310
|
+
id: crypto.createHash('sha256').update(key).digest('hex').slice(0, 16),
|
|
311
|
+
title: release ? releaseTitle(feed.name, found.title) : found.title,
|
|
312
|
+
url: cleanUrl(found.link),
|
|
313
|
+
discussion: found.discussion ?? null,
|
|
314
|
+
summary: summaryFor(found.text, found.title),
|
|
315
|
+
date: found.date,
|
|
316
|
+
time: Math.min(found.date, now),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return items;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function merge(previous, fresh, limit, now) {
|
|
323
|
+
const byKey = new Map(previous.filter((item) => now - item.time <= WINDOW_MS).map((item) => [item.key, item]));
|
|
324
|
+
for (const item of fresh) {
|
|
325
|
+
const stored = byKey.get(item.key);
|
|
326
|
+
if (stored?.date === item.date) item.time = stored.time;
|
|
327
|
+
byKey.set(item.key, item);
|
|
328
|
+
}
|
|
329
|
+
return [...byKey.values()].sort((a, b) => b.time - a.time).slice(0, limit);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readBody(res, maxBytes) {
|
|
333
|
+
const tooLarge = () => new FeedError(`sent more than ${maxBytes / 1024 / 1024} MB`);
|
|
334
|
+
if (Number(res.headers.get('content-length')) > maxBytes) throw tooLarge();
|
|
335
|
+
const chunks = [];
|
|
336
|
+
let size = 0;
|
|
337
|
+
for await (const chunk of res.body ?? []) {
|
|
338
|
+
size += chunk.byteLength;
|
|
339
|
+
if (size > maxBytes) throw tooLarge();
|
|
340
|
+
chunks.push(chunk);
|
|
341
|
+
}
|
|
342
|
+
const bytes = Buffer.concat(chunks);
|
|
343
|
+
const charset = /charset\s*=\s*["']?([\w.:-]+)/i.exec(res.headers.get('content-type') ?? '')?.[1]
|
|
344
|
+
?? /^\s*<\?xml[^>]*encoding\s*=\s*["']([\w.:-]+)/i.exec(bytes.subarray(0, 256).toString('latin1'))?.[1];
|
|
345
|
+
try {
|
|
346
|
+
return new TextDecoder(charset ?? 'utf-8').decode(bytes);
|
|
347
|
+
} catch {
|
|
348
|
+
return new TextDecoder().decode(bytes);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function refusal(res) {
|
|
353
|
+
if ((res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0') return 'GitHub API rate limit exceeded';
|
|
354
|
+
return `HTTP ${res.status}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function failure(err, timeoutMs) {
|
|
358
|
+
if (err instanceof FeedError) return err.message;
|
|
359
|
+
if (err?.name === 'TimeoutError') return `did not answer within ${timeoutMs / 1000} seconds`;
|
|
360
|
+
if (err instanceof SyntaxError) return 'sent data that could not be read';
|
|
361
|
+
return `could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const iso = (time) => (time ? new Date(time).toISOString() : null);
|
|
365
|
+
|
|
366
|
+
export class NewsFeed extends EventEmitter {
|
|
367
|
+
constructor({
|
|
368
|
+
registry = null, feeds = FEEDS, fetchImpl = fetch, ttlMs = TTL_MS, retryMs = RETRY_MS, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_BYTES,
|
|
369
|
+
} = {}) {
|
|
370
|
+
super();
|
|
371
|
+
this.registry = registry;
|
|
372
|
+
this.fetchImpl = fetchImpl;
|
|
373
|
+
this.ttlMs = ttlMs;
|
|
374
|
+
this.retryMs = retryMs;
|
|
375
|
+
this.timeoutMs = timeoutMs;
|
|
376
|
+
this.maxBytes = maxBytes;
|
|
377
|
+
this.states = feeds.map((feed) => ({ feed, items: [], etag: null, lastModified: null, checkedAt: 0, okAt: 0, error: null }));
|
|
378
|
+
this.refreshedAt = 0;
|
|
379
|
+
this.refreshing = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
snapshot() {
|
|
383
|
+
const active = this.states.filter((state) => !state.feed.provider || this._installed(state.feed.provider));
|
|
384
|
+
this._refreshDue(active);
|
|
385
|
+
return this._describe(active);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_installed(id) {
|
|
389
|
+
const provider = this.registry?.providers?.find((p) => p.id === id);
|
|
390
|
+
try {
|
|
391
|
+
return Boolean(provider && this.registry.resolve(provider));
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_refreshDue(active) {
|
|
398
|
+
if (this.refreshing) return;
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
const due = active.filter((state) => now - state.checkedAt >= (state.error ? this.retryMs : state.feed.every ?? this.ttlMs));
|
|
401
|
+
if (due.length === 0) return;
|
|
402
|
+
this.refreshing = this._refresh(due).finally(() => {
|
|
403
|
+
this.refreshing = null;
|
|
404
|
+
this.emit('updated');
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async _refresh(states) {
|
|
409
|
+
const queue = [...states];
|
|
410
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
411
|
+
while (queue.length) await this._update(queue.shift());
|
|
412
|
+
}));
|
|
413
|
+
this.refreshedAt = Date.now();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async _update(state) {
|
|
417
|
+
const { feed } = state;
|
|
418
|
+
state.checkedAt = Date.now();
|
|
419
|
+
try {
|
|
420
|
+
const headers = { 'User-Agent': USER_AGENT, Accept: feed.format === 'github' ? 'application/vnd.github+json' : feed.format === 'hn' ? 'application/json' : FEED_ACCEPT };
|
|
421
|
+
if (feed.format === 'github') headers['X-GitHub-Api-Version'] = '2022-11-28';
|
|
422
|
+
if (state.etag) headers['If-None-Match'] = state.etag;
|
|
423
|
+
if (state.lastModified) headers['If-Modified-Since'] = state.lastModified;
|
|
424
|
+
const res = await this.fetchImpl(feed.url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
425
|
+
if (res.status !== 304) {
|
|
426
|
+
if (!res.ok) throw new FeedError(refusal(res));
|
|
427
|
+
const text = await readBody(res, this.maxBytes);
|
|
428
|
+
const entries = feed.format === 'hn' ? parseHackerNews(text) : feed.format === 'github' ? parseGithubRelease(text, feed.name) : feedEntries(text);
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
state.items = merge(state.items, select(feed, entries, now), LIMITS[feed.category], now);
|
|
431
|
+
state.etag = res.headers.get('etag');
|
|
432
|
+
state.lastModified = res.headers.get('last-modified');
|
|
433
|
+
}
|
|
434
|
+
state.error = null;
|
|
435
|
+
state.okAt = Date.now();
|
|
436
|
+
} catch (err) {
|
|
437
|
+
state.error = failure(err, this.timeoutMs);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_describe(active) {
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const taken = new Set();
|
|
444
|
+
const items = [];
|
|
445
|
+
for (const { feed, items: own } of active) {
|
|
446
|
+
for (const item of own) {
|
|
447
|
+
if (now - item.time > WINDOW_MS || taken.has(item.key)) continue;
|
|
448
|
+
taken.add(item.key);
|
|
449
|
+
items.push({ feed, item });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
items.sort((a, b) => b.item.time - a.item.time);
|
|
453
|
+
return {
|
|
454
|
+
refreshedAt: iso(this.refreshedAt),
|
|
455
|
+
refreshing: Boolean(this.refreshing),
|
|
456
|
+
sources: active.map(({ feed, error, okAt }) => ({ id: feed.id, name: feed.name, category: feed.category, error, okAt: iso(okAt) })),
|
|
457
|
+
items: items.map(({ feed, item }) => ({
|
|
458
|
+
id: item.id,
|
|
459
|
+
title: item.title,
|
|
460
|
+
url: item.url,
|
|
461
|
+
discussion: item.discussion,
|
|
462
|
+
summary: item.summary,
|
|
463
|
+
source: feed.name,
|
|
464
|
+
sourceId: feed.id,
|
|
465
|
+
category: feed.category,
|
|
466
|
+
publishedAt: iso(item.time),
|
|
467
|
+
})),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -101,6 +101,7 @@ export function createManagerServer({
|
|
|
101
101
|
registry,
|
|
102
102
|
usage,
|
|
103
103
|
modelStats,
|
|
104
|
+
news = null,
|
|
104
105
|
token,
|
|
105
106
|
host = '127.0.0.1',
|
|
106
107
|
port = 0,
|
|
@@ -230,6 +231,9 @@ export function createManagerServer({
|
|
|
230
231
|
if (route === '/model-stats' && method === 'GET') {
|
|
231
232
|
return sendJson(res, 200, await modelStats.snapshot(manager.list()));
|
|
232
233
|
}
|
|
234
|
+
if (route === '/news' && method === 'GET' && news) {
|
|
235
|
+
return sendJson(res, 200, news.snapshot());
|
|
236
|
+
}
|
|
233
237
|
const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
|
|
234
238
|
if (installMatch && method === 'POST') {
|
|
235
239
|
const body = await readJsonBody(req);
|
|
@@ -342,6 +346,7 @@ export function createManagerServer({
|
|
|
342
346
|
manager.on('event', broadcast);
|
|
343
347
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
344
348
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
349
|
+
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
345
350
|
|
|
346
351
|
function handleEvents(ws) {
|
|
347
352
|
eventClients.add(ws);
|
package/web/app.js
CHANGED
|
@@ -5,6 +5,8 @@ const TOKEN_KEY = 'agentGuild.token';
|
|
|
5
5
|
const CWD_KEY = 'agentGuild.cwd';
|
|
6
6
|
const ACCOUNTS_KEY = 'agentGuild.accounts';
|
|
7
7
|
const THEME_KEY = 'agentGuild.theme';
|
|
8
|
+
const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
|
|
9
|
+
const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
|
|
8
10
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
9
11
|
|
|
10
12
|
const $ = (id) => document.getElementById(id);
|
|
@@ -15,6 +17,7 @@ const state = {
|
|
|
15
17
|
accounts: {},
|
|
16
18
|
stats: null,
|
|
17
19
|
statsFor: new Map(),
|
|
20
|
+
news: null,
|
|
18
21
|
sessions: new Map(),
|
|
19
22
|
views: new Map(),
|
|
20
23
|
activeId: null,
|
|
@@ -900,6 +903,291 @@ function closeModels() {
|
|
|
900
903
|
if ($('models').open) $('models').close();
|
|
901
904
|
}
|
|
902
905
|
|
|
906
|
+
const NEWS_LATEST = 5;
|
|
907
|
+
const NEWS_POLL_MS = 10 * 60 * 1000;
|
|
908
|
+
const NEWS_FILTERS = [['all', 'All'], ['news', 'News'], ['releases', 'Releases'], ['research', 'Research']];
|
|
909
|
+
const newsView = { shown: null, since: Infinity, filter: 'all', opener: null };
|
|
910
|
+
let newsLoading = null;
|
|
911
|
+
let newsAgain = false;
|
|
912
|
+
let newsLoadedAt = 0;
|
|
913
|
+
|
|
914
|
+
function newsSeen() {
|
|
915
|
+
const seen = Date.parse(load(NEWS_SEEN_KEY));
|
|
916
|
+
return Number.isFinite(seen) ? seen : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function newestTime(items) {
|
|
920
|
+
return items.reduce((newest, item) => Math.max(newest, Date.parse(item.publishedAt) || 0), 0);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function loadNews() {
|
|
924
|
+
if (newsLoading) {
|
|
925
|
+
newsAgain = true;
|
|
926
|
+
return newsLoading;
|
|
927
|
+
}
|
|
928
|
+
newsLoading = api('GET', '/news').then(setNews, () => {}).finally(() => {
|
|
929
|
+
newsLoading = null;
|
|
930
|
+
newsLoadedAt = Date.now();
|
|
931
|
+
if (newsAgain) {
|
|
932
|
+
newsAgain = false;
|
|
933
|
+
loadNews();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
return newsLoading;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function setNews(news) {
|
|
940
|
+
state.news = news;
|
|
941
|
+
if (newsSeen() === null && news.items.length) save(NEWS_SEEN_KEY, new Date(newestTime(news.items)).toISOString());
|
|
942
|
+
renderLatestNews();
|
|
943
|
+
if ($('news').open) updateNewsPanel();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function webHref(value) {
|
|
947
|
+
try {
|
|
948
|
+
const url = new URL(value);
|
|
949
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function newsTitle(item) {
|
|
956
|
+
const href = webHref(item.url);
|
|
957
|
+
const title = document.createElement(href ? 'a' : 'span');
|
|
958
|
+
title.className = 'news-link';
|
|
959
|
+
title.textContent = item.title;
|
|
960
|
+
if (href) {
|
|
961
|
+
title.href = href;
|
|
962
|
+
title.target = '_blank';
|
|
963
|
+
title.rel = 'noopener noreferrer';
|
|
964
|
+
title.setAttribute('aria-description', 'Opens in a new tab');
|
|
965
|
+
}
|
|
966
|
+
return title;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function newsMeta(item) {
|
|
970
|
+
const meta = document.createElement('span');
|
|
971
|
+
meta.className = 'news-meta';
|
|
972
|
+
const time = document.createElement('time');
|
|
973
|
+
time.dateTime = item.publishedAt;
|
|
974
|
+
time.textContent = relativeTime(item.publishedAt);
|
|
975
|
+
time.title = new Date(item.publishedAt).toLocaleString();
|
|
976
|
+
meta.append(`${item.source} · `, time);
|
|
977
|
+
return meta;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function isNew(item, since) {
|
|
981
|
+
return Date.parse(item.publishedAt) > since;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function newBadge() {
|
|
985
|
+
return badge('new', 'New', 'Published since you last opened the news');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function latestNews(items) {
|
|
989
|
+
const sources = new Set();
|
|
990
|
+
const latest = [];
|
|
991
|
+
for (const item of items) {
|
|
992
|
+
if (item.category !== 'news' || sources.has(item.source)) continue;
|
|
993
|
+
sources.add(item.source);
|
|
994
|
+
latest.push(item);
|
|
995
|
+
if (latest.length === NEWS_LATEST) break;
|
|
996
|
+
}
|
|
997
|
+
return latest;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function failureLines(news) {
|
|
1001
|
+
const groups = new Map();
|
|
1002
|
+
for (const source of news.sources) if (source.error) groups.set(source.error, [...(groups.get(source.error) ?? []), source]);
|
|
1003
|
+
return [...groups].map(([error, sources]) => {
|
|
1004
|
+
if (sources.length === 1) {
|
|
1005
|
+
const [source] = sources;
|
|
1006
|
+
return `${source.name}: ${error} · ${source.okAt ? `showing items fetched ${relativeTime(source.okAt)}` : 'nothing fetched yet'}`;
|
|
1007
|
+
}
|
|
1008
|
+
const names = [...new Set(sources.map((source) => source.name))];
|
|
1009
|
+
return `${names.length <= 3 ? listJoin(names) : `${sources.length} sources`}: ${error}`;
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function newsNote(news) {
|
|
1014
|
+
if (!news || (news.refreshing && news.items.length === 0)) return 'Loading news…';
|
|
1015
|
+
const failed = news.sources.filter((s) => s.error);
|
|
1016
|
+
if (news.items.length === 0 && failed.length > 0 && failed.length === news.sources.length) {
|
|
1017
|
+
return `News could not be loaded. ${failureLines(news)[0]}. Agent Guild tries again in a few minutes.`;
|
|
1018
|
+
}
|
|
1019
|
+
return 'No news from the last 30 days.';
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderLatestNews() {
|
|
1023
|
+
const news = state.news;
|
|
1024
|
+
const list = $('news-latest');
|
|
1025
|
+
const seen = newsSeen() ?? Infinity;
|
|
1026
|
+
const items = news ? latestNews(news.items) : [];
|
|
1027
|
+
const focused = document.activeElement?.closest?.('#news-latest .news-row')?.dataset.id;
|
|
1028
|
+
list.replaceChildren(...items.map((item) => {
|
|
1029
|
+
const row = document.createElement('li');
|
|
1030
|
+
row.className = 'news-row';
|
|
1031
|
+
row.dataset.id = item.id;
|
|
1032
|
+
row.append(newsTitle(item), ...(isNew(item, seen) ? [newBadge()] : []), newsMeta(item));
|
|
1033
|
+
return row;
|
|
1034
|
+
}));
|
|
1035
|
+
if (focused) [...list.children].find((row) => row.dataset.id === focused)?.querySelector('.news-link')?.focus({ preventScroll: true });
|
|
1036
|
+
list.hidden = items.length === 0;
|
|
1037
|
+
const fresh = news ? news.items.filter((item) => item.category === 'news' && isNew(item, seen)).length : 0;
|
|
1038
|
+
$('news-count').textContent = fresh ? `· ${fresh} new` : '';
|
|
1039
|
+
$('news-note').textContent = items.length ? '' : newsNote(news);
|
|
1040
|
+
$('news-note').hidden = items.length > 0;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function dayLabel(time) {
|
|
1044
|
+
const date = new Date(time);
|
|
1045
|
+
const today = new Date();
|
|
1046
|
+
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
|
1047
|
+
if (date.toDateString() === today.toDateString()) return 'Today';
|
|
1048
|
+
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
|
1049
|
+
return date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function newsEntry(item) {
|
|
1053
|
+
const entry = document.createElement('li');
|
|
1054
|
+
entry.className = 'news-item';
|
|
1055
|
+
entry.dataset.id = item.id;
|
|
1056
|
+
const head = document.createElement('div');
|
|
1057
|
+
head.className = 'news-head';
|
|
1058
|
+
head.append(newsTitle(item), ...(isNew(item, newsView.since) ? [newBadge()] : []));
|
|
1059
|
+
entry.append(head, newsMeta(item));
|
|
1060
|
+
if (item.summary) {
|
|
1061
|
+
const summary = document.createElement('p');
|
|
1062
|
+
summary.className = 'news-summary';
|
|
1063
|
+
const discussion = webHref(item.discussion);
|
|
1064
|
+
if (discussion) {
|
|
1065
|
+
const link = document.createElement('a');
|
|
1066
|
+
link.className = 'news-discussion';
|
|
1067
|
+
link.href = discussion;
|
|
1068
|
+
link.target = '_blank';
|
|
1069
|
+
link.rel = 'noopener noreferrer';
|
|
1070
|
+
link.textContent = item.summary;
|
|
1071
|
+
link.setAttribute('aria-description', `Opens the discussion on ${item.source} in a new tab`);
|
|
1072
|
+
summary.append(link);
|
|
1073
|
+
} else {
|
|
1074
|
+
summary.textContent = item.summary;
|
|
1075
|
+
}
|
|
1076
|
+
entry.append(summary);
|
|
1077
|
+
}
|
|
1078
|
+
return entry;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function renderNewsFilters() {
|
|
1082
|
+
$('news-filters').replaceChildren(...NEWS_FILTERS.map(([id, label]) => {
|
|
1083
|
+
const chip = document.createElement('button');
|
|
1084
|
+
chip.type = 'button';
|
|
1085
|
+
chip.className = 'news-filter';
|
|
1086
|
+
chip.textContent = label;
|
|
1087
|
+
chip.setAttribute('aria-pressed', String(id === newsView.filter));
|
|
1088
|
+
chip.addEventListener('click', () => {
|
|
1089
|
+
newsView.filter = id;
|
|
1090
|
+
save(NEWS_FILTER_KEY, id);
|
|
1091
|
+
for (const other of $('news-filters').children) other.setAttribute('aria-pressed', String(other === chip));
|
|
1092
|
+
newsView.shown = state.news;
|
|
1093
|
+
renderNewsList();
|
|
1094
|
+
document.querySelector('.news-body').scrollTop = 0;
|
|
1095
|
+
});
|
|
1096
|
+
return chip;
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function renderNewsList() {
|
|
1101
|
+
const shown = newsView.shown;
|
|
1102
|
+
const items = (shown?.items ?? []).filter((item) => newsView.filter === 'all' || item.category === newsView.filter);
|
|
1103
|
+
const groups = [];
|
|
1104
|
+
for (const item of items) {
|
|
1105
|
+
const label = dayLabel(Date.parse(item.publishedAt));
|
|
1106
|
+
if (groups.at(-1)?.label !== label) groups.push({ label, items: [] });
|
|
1107
|
+
groups.at(-1).items.push(item);
|
|
1108
|
+
}
|
|
1109
|
+
$('news-list').replaceChildren(...groups.map((group) => {
|
|
1110
|
+
const section = document.createElement('section');
|
|
1111
|
+
section.className = 'news-group';
|
|
1112
|
+
const heading = document.createElement('h3');
|
|
1113
|
+
heading.className = 'news-day';
|
|
1114
|
+
heading.textContent = group.label;
|
|
1115
|
+
const list = document.createElement('ul');
|
|
1116
|
+
list.className = 'news-items';
|
|
1117
|
+
list.append(...group.items.map(newsEntry));
|
|
1118
|
+
section.append(heading, list);
|
|
1119
|
+
return section;
|
|
1120
|
+
}));
|
|
1121
|
+
const label = NEWS_FILTERS.find(([id]) => id === newsView.filter)[1];
|
|
1122
|
+
$('news-empty').textContent = items.length ? '' : newsView.filter === 'all' || !shown ? newsNote(shown) : `Nothing in ${label} from the last 30 days.`;
|
|
1123
|
+
$('news-empty').hidden = items.length > 0;
|
|
1124
|
+
$('news-fresh').hidden = true;
|
|
1125
|
+
const fresh = (shown?.items ?? []).filter((item) => isNew(item, newsView.since)).length;
|
|
1126
|
+
$('news-sub').textContent = fresh ? `${fresh} new since your last visit` : 'Newest first';
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function renderNewsStatus() {
|
|
1130
|
+
const news = state.news;
|
|
1131
|
+
const status = $('news-status');
|
|
1132
|
+
status.hidden = !news;
|
|
1133
|
+
if (!news) return;
|
|
1134
|
+
const updated = news.refreshing ? 'checking for news…' : news.refreshedAt ? `updated ${relativeTime(news.refreshedAt)}` : '';
|
|
1135
|
+
const lines = [[`${news.sources.length} source${news.sources.length === 1 ? '' : 's'}`, updated].filter(Boolean).join(' · '), ...failureLines(news)];
|
|
1136
|
+
status.replaceChildren(...lines.map((text) => {
|
|
1137
|
+
const line = document.createElement('span');
|
|
1138
|
+
line.textContent = text;
|
|
1139
|
+
return line;
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function updateNewsPanel() {
|
|
1144
|
+
renderNewsStatus();
|
|
1145
|
+
if (!newsView.shown?.items.length) {
|
|
1146
|
+
newsView.shown = state.news;
|
|
1147
|
+
renderNewsList();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const shownIds = new Set(newsView.shown.items.map((item) => item.id));
|
|
1151
|
+
const added = state.news.items.filter((item) => !shownIds.has(item.id) && (newsView.filter === 'all' || item.category === newsView.filter)).length;
|
|
1152
|
+
const fresh = $('news-fresh');
|
|
1153
|
+
fresh.hidden = added === 0;
|
|
1154
|
+
fresh.textContent = `Show ${added} new item${added === 1 ? '' : 's'}`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function showFreshNews() {
|
|
1158
|
+
const before = new Set(newsView.shown.items.map((item) => item.id));
|
|
1159
|
+
newsView.shown = state.news;
|
|
1160
|
+
renderNewsList();
|
|
1161
|
+
const first = [...$('news-list').querySelectorAll('.news-item')].find((entry) => !before.has(entry.dataset.id));
|
|
1162
|
+
(first?.querySelector('.news-link') ?? $('news-close')).focus();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function openNews() {
|
|
1166
|
+
const dialog = $('news');
|
|
1167
|
+
const saved = load(NEWS_FILTER_KEY);
|
|
1168
|
+
newsView.filter = NEWS_FILTERS.some(([id]) => id === saved) ? saved : 'all';
|
|
1169
|
+
newsView.since = newsSeen() ?? Infinity;
|
|
1170
|
+
newsView.shown = state.news;
|
|
1171
|
+
renderNewsFilters();
|
|
1172
|
+
renderNewsList();
|
|
1173
|
+
renderNewsStatus();
|
|
1174
|
+
if (!dialog.open) {
|
|
1175
|
+
newsView.opener = document.activeElement;
|
|
1176
|
+
dialog.showModal();
|
|
1177
|
+
}
|
|
1178
|
+
($('news-list').querySelector('.news-link') ?? $('news-close')).focus();
|
|
1179
|
+
if (!state.news) loadNews();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function closeNews() {
|
|
1183
|
+
if ($('news').open) $('news').close();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function tickNews() {
|
|
1187
|
+
for (const time of document.querySelectorAll('#news-latest time, #news-list time')) time.textContent = relativeTime(time.dateTime);
|
|
1188
|
+
if ($('news').open) renderNewsStatus();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
903
1191
|
const CHANNEL_LABELS = {
|
|
904
1192
|
npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
|
|
905
1193
|
};
|
|
@@ -1343,6 +1631,7 @@ function enterStopping(running = 0) {
|
|
|
1343
1631
|
state.stopRemaining = null;
|
|
1344
1632
|
closePanel();
|
|
1345
1633
|
closeModels();
|
|
1634
|
+
closeNews();
|
|
1346
1635
|
for (const view of state.views.values()) view.dispose();
|
|
1347
1636
|
state.views.clear();
|
|
1348
1637
|
state.sessions.clear();
|
|
@@ -1400,6 +1689,9 @@ function connectEvents() {
|
|
|
1400
1689
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
1401
1690
|
renderSessions();
|
|
1402
1691
|
setUpgrade(msg.upgrade);
|
|
1692
|
+
loadNews();
|
|
1693
|
+
} else if (msg.type === 'news.updated') {
|
|
1694
|
+
loadNews();
|
|
1403
1695
|
} else if (msg.type === 'manager.upgrade') {
|
|
1404
1696
|
setUpgrade(msg.upgrade);
|
|
1405
1697
|
} else if (msg.type === 'manager.stopping') {
|
|
@@ -1453,9 +1745,11 @@ function readTokenFromHash() {
|
|
|
1453
1745
|
|
|
1454
1746
|
let usageTimer;
|
|
1455
1747
|
let statsInterval;
|
|
1748
|
+
let newsTimer;
|
|
1456
1749
|
|
|
1457
1750
|
function showAuth(message = '') {
|
|
1458
1751
|
closeModels();
|
|
1752
|
+
closeNews();
|
|
1459
1753
|
$('app').hidden = true;
|
|
1460
1754
|
$('terminal-panel').hidden = true;
|
|
1461
1755
|
$('stopped').hidden = true;
|
|
@@ -1488,6 +1782,8 @@ async function boot() {
|
|
|
1488
1782
|
loadStats();
|
|
1489
1783
|
clearInterval(statsInterval);
|
|
1490
1784
|
statsInterval = setInterval(loadStats, 60 * 60 * 1000);
|
|
1785
|
+
clearInterval(newsTimer);
|
|
1786
|
+
newsTimer = setInterval(() => { if (document.visibilityState === 'visible') loadNews(); }, NEWS_POLL_MS);
|
|
1491
1787
|
}
|
|
1492
1788
|
|
|
1493
1789
|
$('auth-form').addEventListener('submit', (e) => {
|
|
@@ -1530,6 +1826,21 @@ document.addEventListener('keydown', (e) => {
|
|
|
1530
1826
|
}, true);
|
|
1531
1827
|
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
1532
1828
|
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
1829
|
+
$('news-all').addEventListener('click', openNews);
|
|
1830
|
+
$('news-close').addEventListener('click', closeNews);
|
|
1831
|
+
$('news-fresh').addEventListener('click', showFreshNews);
|
|
1832
|
+
$('news').addEventListener('click', (e) => { if (e.target === $('news')) closeNews(); });
|
|
1833
|
+
$('news').addEventListener('close', () => {
|
|
1834
|
+
const newest = newestTime(newsView.shown?.items ?? []);
|
|
1835
|
+
if (newest > (newsSeen() ?? 0)) save(NEWS_SEEN_KEY, new Date(newest).toISOString());
|
|
1836
|
+
renderLatestNews();
|
|
1837
|
+
const opener = newsView.opener?.isConnected && !newsView.opener.closest('[hidden]') ? newsView.opener : $('news-all');
|
|
1838
|
+
opener.focus();
|
|
1839
|
+
newsView.opener = null;
|
|
1840
|
+
});
|
|
1841
|
+
document.addEventListener('visibilitychange', () => {
|
|
1842
|
+
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
1843
|
+
});
|
|
1533
1844
|
$('models-more').addEventListener('click', () => {
|
|
1534
1845
|
const before = $('models-list').childElementCount;
|
|
1535
1846
|
modelsView.all = true;
|
|
@@ -1554,6 +1865,7 @@ $('cwd').value = load(CWD_KEY) || '';
|
|
|
1554
1865
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
1555
1866
|
if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
|
|
1556
1867
|
setInterval(renderSessions, 30000);
|
|
1868
|
+
setInterval(tickNews, 30000);
|
|
1557
1869
|
|
|
1558
1870
|
// The terminal panel sits below the top bar, which wraps onto two rows on
|
|
1559
1871
|
// narrow screens; publish its height so the panel never covers its controls.
|
package/web/art/art.css
CHANGED
|
@@ -155,6 +155,26 @@
|
|
|
155
155
|
.badge { background: rgba(0, 0, 0, 0.45); border-color: #4a3d25; }
|
|
156
156
|
.model-pill:hover { border-color: var(--gold-dim); }
|
|
157
157
|
|
|
158
|
+
.news-latest {
|
|
159
|
+
border-radius: 6px; border-color: #4a3d25; background: var(--card-bg);
|
|
160
|
+
box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
161
|
+
}
|
|
162
|
+
.news-row + .news-row, .news-bar, .news-filters, .news-item, .news-status { border-color: #2a2418; }
|
|
163
|
+
.news-link { color: #f3ead8; }
|
|
164
|
+
a.news-link:visited { color: var(--muted); }
|
|
165
|
+
.news-panel {
|
|
166
|
+
background: #12141a; border-left-color: #4a3d25;
|
|
167
|
+
box-shadow: inset 1px 0 0 rgba(232, 196, 124, 0.08), -24px 0 60px rgba(0, 0, 0, 0.6);
|
|
168
|
+
}
|
|
169
|
+
.news-panel::backdrop { background: rgba(5, 6, 9, 0.72); }
|
|
170
|
+
.news-bar { padding: 16px 18px 12px; }
|
|
171
|
+
.news-filters, .news-status { padding-left: 18px; padding-right: 18px; }
|
|
172
|
+
.news-body { padding: 0 18px 14px; }
|
|
173
|
+
.news-day { background: #12141a; color: #c9ab72; }
|
|
174
|
+
.news-filter { border-color: #4a3d25; background: rgba(0, 0, 0, 0.35); }
|
|
175
|
+
.news-filter:hover { border-color: var(--gold-dim); }
|
|
176
|
+
.news-filter[aria-pressed="true"] { border-color: #b8955a; color: #fff; background: linear-gradient(#8e8ef6, #6262d6); }
|
|
177
|
+
|
|
158
178
|
.session-card { padding-left: 152px; min-height: 196px; }
|
|
159
179
|
.session-card::before {
|
|
160
180
|
inset: 0 auto 0 0; width: 168px; background-size: 168px auto;
|
package/web/index.html
CHANGED
|
@@ -47,6 +47,15 @@
|
|
|
47
47
|
<div id="sessions" class="session-grid" role="list"></div>
|
|
48
48
|
<p id="empty" class="empty">No sessions yet. Pick a provider above to start one. Sessions keep running when you close this page.</p>
|
|
49
49
|
</section>
|
|
50
|
+
|
|
51
|
+
<section class="news" aria-labelledby="news-heading">
|
|
52
|
+
<div class="section-head">
|
|
53
|
+
<h2 id="news-heading">Latest news <span id="news-count" class="count"></span></h2>
|
|
54
|
+
<button id="news-all" class="btn" type="button" aria-haspopup="dialog">All news</button>
|
|
55
|
+
</div>
|
|
56
|
+
<ol id="news-latest" class="news-latest" hidden></ol>
|
|
57
|
+
<p id="news-note" class="empty">Loading news…</p>
|
|
58
|
+
</section>
|
|
50
59
|
</main>
|
|
51
60
|
|
|
52
61
|
<section id="auth" class="auth" hidden>
|
|
@@ -105,6 +114,23 @@
|
|
|
105
114
|
<p id="models-source" class="models-source"></p>
|
|
106
115
|
</dialog>
|
|
107
116
|
|
|
117
|
+
<dialog id="news" class="news-panel" aria-labelledby="news-title">
|
|
118
|
+
<div class="news-bar">
|
|
119
|
+
<div class="news-heading">
|
|
120
|
+
<h2 id="news-title">News</h2>
|
|
121
|
+
<p id="news-sub" class="sub"></p>
|
|
122
|
+
</div>
|
|
123
|
+
<button id="news-close" class="btn" type="button">Close</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div id="news-filters" class="news-filters" role="group" aria-label="Show"></div>
|
|
126
|
+
<div class="news-body">
|
|
127
|
+
<button id="news-fresh" class="btn news-fresh" type="button" hidden></button>
|
|
128
|
+
<div id="news-list"></div>
|
|
129
|
+
<p id="news-empty" class="news-empty" hidden></p>
|
|
130
|
+
</div>
|
|
131
|
+
<p id="news-status" class="news-status" hidden></p>
|
|
132
|
+
</dialog>
|
|
133
|
+
|
|
108
134
|
<div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
|
|
109
135
|
|
|
110
136
|
<div id="toast" class="toast" role="alert" hidden></div>
|
package/web/styles.css
CHANGED
|
@@ -341,6 +341,59 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
341
341
|
.models-note { margin: 14px 0 0; color: var(--muted); }
|
|
342
342
|
.models-source { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
|
|
343
343
|
|
|
344
|
+
.news-latest {
|
|
345
|
+
list-style: none; margin: 0; padding: 0;
|
|
346
|
+
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
|
|
347
|
+
}
|
|
348
|
+
.news-row { display: flex; align-items: baseline; gap: 4px 10px; min-width: 0; padding: 9px 14px; }
|
|
349
|
+
.news-row + .news-row { border-top: 1px solid var(--border); }
|
|
350
|
+
.news-row .news-link { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
351
|
+
.news-row .badge, .news-row .news-meta { flex: none; }
|
|
352
|
+
.news-link { color: var(--text); font-weight: 600; text-decoration: none; }
|
|
353
|
+
a.news-link:hover, .news-discussion:hover { text-decoration: underline; }
|
|
354
|
+
a.news-link:visited { color: var(--muted); }
|
|
355
|
+
.news-link:focus-visible, .news-discussion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
|
356
|
+
.news-meta { color: var(--muted); font-size: 0.78rem; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
357
|
+
|
|
358
|
+
.news-panel {
|
|
359
|
+
width: min(460px, 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; padding: 0;
|
|
360
|
+
border: 0; border-left: 1px solid var(--border); background: var(--surface); color: var(--text);
|
|
361
|
+
box-shadow: -16px 0 48px rgba(0, 0, 0, 0.25);
|
|
362
|
+
}
|
|
363
|
+
.news-panel[open] { display: flex; flex-direction: column; animation: news-in 0.18s ease-out; }
|
|
364
|
+
.news-panel::backdrop { background: rgba(15, 17, 21, 0.4); }
|
|
365
|
+
.news-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
366
|
+
.news-heading { flex: 1; min-width: 0; }
|
|
367
|
+
.news-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
|
|
368
|
+
.news-filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
369
|
+
.news-filter {
|
|
370
|
+
font: inherit; font-size: 0.75rem; font-weight: 600; padding: 3px 11px; border-radius: 999px; cursor: pointer;
|
|
371
|
+
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
|
|
372
|
+
}
|
|
373
|
+
.news-filter:hover { border-color: var(--muted); }
|
|
374
|
+
.news-filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
375
|
+
.news-filter[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
|
376
|
+
.news-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
|
|
377
|
+
.news-fresh { width: 100%; margin-top: 12px; }
|
|
378
|
+
.news-day {
|
|
379
|
+
position: sticky; top: 0; z-index: 1; margin: 0; padding: 12px 0 6px; background: var(--surface);
|
|
380
|
+
color: var(--muted); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
|
|
381
|
+
}
|
|
382
|
+
.news-items { list-style: none; margin: 0; padding: 0; }
|
|
383
|
+
.news-item { padding: 10px 0; border-top: 1px solid var(--border); }
|
|
384
|
+
.news-head { display: flex; align-items: baseline; gap: 6px; }
|
|
385
|
+
.news-head .news-link { overflow-wrap: anywhere; }
|
|
386
|
+
.news-head .badge { flex: none; margin-left: auto; }
|
|
387
|
+
.news-item .news-meta { display: block; margin-top: 3px; }
|
|
388
|
+
.news-summary {
|
|
389
|
+
margin: 4px 0 0; color: var(--muted); font-size: 0.82rem; line-height: 1.45; overflow-wrap: anywhere;
|
|
390
|
+
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
|
391
|
+
}
|
|
392
|
+
.news-discussion { color: inherit; text-decoration: none; }
|
|
393
|
+
.news-empty { margin: 14px 0 0; color: var(--muted); }
|
|
394
|
+
.news-status { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
|
|
395
|
+
.news-status span { display: block; }
|
|
396
|
+
|
|
344
397
|
.toast {
|
|
345
398
|
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20;
|
|
346
399
|
max-width: min(640px, calc(100vw - 32px)); padding: 10px 14px; border-radius: 8px;
|
|
@@ -350,9 +403,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
350
403
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
|
351
404
|
@keyframes ring { 0% { transform: scale(0.85); opacity: 0.8; } 100% { transform: scale(1.3); opacity: 0; } }
|
|
352
405
|
@keyframes stat-fill { from { transform: scaleX(0); } }
|
|
406
|
+
@keyframes news-in { from { transform: translateX(24px); opacity: 0; } }
|
|
353
407
|
|
|
354
408
|
@media (prefers-reduced-motion: reduce) {
|
|
355
|
-
.status-pill.active::before, .agent.working::after, .model-detail .stat-fill { animation: none; }
|
|
409
|
+
.status-pill.active::before, .agent.working::after, .model-detail .stat-fill, .news-panel[open] { animation: none; }
|
|
356
410
|
.model-name::before { transition: none; }
|
|
357
411
|
}
|
|
358
412
|
|
|
@@ -370,4 +424,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
|
|
|
370
424
|
.model-detail .stat { padding-bottom: 6px; }
|
|
371
425
|
.model-detail .stat-reading { row-gap: 1px; }
|
|
372
426
|
.model-detail .stat-note { grid-column: 1 / -1; }
|
|
427
|
+
.news-row { flex-wrap: wrap; }
|
|
428
|
+
.news-row .news-link { flex-basis: 100%; white-space: normal; }
|
|
429
|
+
.news-panel { border-left: 0; }
|
|
373
430
|
}
|