mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 01:49:40 +02:00
feat(articles): M6 AI tools — list / save / archive / tag / highlight
Five new entries in AI_TOOL_CATALOG (shared-ai/src/tools/schemas.ts):
list_articles auto Read-only listing with status +
query filter. Default hides
archived; 'all' includes them.
save_article propose URL → Readability → encrypted save.
Delegates to articlesStore.saveFromUrl
which already handles scope-aware
dedupe. Duplicates surface as
success:true with duplicate:true.
archive_article propose setStatus('archived') after
scoped existence check.
tag_article propose Case-insensitive dedupe over
globalTags; tagMutations.createTag
fills in when missing. Junction
write via articleTagOps.addTag.
add_article_highlight propose Snaps to the first verbatim
occurrence of `text` in the
decrypted article.content. Fails
cleanly when the snippet isn't
found — no orphan highlights.
Policy, client executor, and server planner derive automatically from
the catalog (see root CLAUDE.md §"AI Tool Catalog") so no manual
registration in policy.ts / services/mana-ai is needed.
Skipped from the M6 plan: <AiProposalInbox module="articles" />. The
component doesn't exist in the current codebase — after the
pendingProposals-table drop in Dexie v29 the inbox surface moved to
the mission-detail cross-module view, and articles proposals show up
there automatically. Documented in docs/plans/articles-module.md.
Also updated: plan doc now marks M1–M6 as DONE with commit refs and
the next-step pointer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12be75e6a6
commit
5924f4fac3
4 changed files with 447 additions and 1 deletions
|
|
@ -31,6 +31,7 @@ import { inventoryTools } from '$lib/modules/inventory/tools';
|
|||
import { plantsTools } from '$lib/modules/plants/tools';
|
||||
import { newsTools } from '$lib/modules/news/tools';
|
||||
import { newsResearchTools } from '$lib/modules/news-research/tools';
|
||||
import { articlesTools } from '$lib/modules/articles/tools';
|
||||
import { recipesTools } from '$lib/modules/recipes/tools';
|
||||
import { questionsTools } from '$lib/modules/questions/tools';
|
||||
import { meditateTools } from '$lib/modules/meditate/tools';
|
||||
|
|
@ -76,6 +77,7 @@ export function initTools(): void {
|
|||
registerTools(plantsTools);
|
||||
registerTools(newsTools);
|
||||
registerTools(newsResearchTools);
|
||||
registerTools(articlesTools);
|
||||
registerTools(recipesTools);
|
||||
registerTools(questionsTools);
|
||||
registerTools(meditateTools);
|
||||
|
|
|
|||
308
apps/mana/apps/web/src/lib/modules/articles/tools.ts
Normal file
308
apps/mana/apps/web/src/lib/modules/articles/tools.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* Articles AI Tools — LLM-accessible operations for the articles module.
|
||||
*
|
||||
* Catalog entries live in `@mana/shared-ai/src/tools/schemas.ts` and drive
|
||||
* the policy layer + server planner automatically; this file contributes
|
||||
* the execute-side glue.
|
||||
*
|
||||
* list_articles auto Read-only listing for agent context.
|
||||
* save_article propose URL → Readability → encrypted save.
|
||||
* Legacy `save_news_article` kept as
|
||||
* alias in `modules/news/tools.ts`.
|
||||
* archive_article propose Flips status → 'archived'.
|
||||
* tag_article propose Creates (or reuses) a global tag by
|
||||
* name and links it to the article.
|
||||
* add_article_highlight propose Persists a highlight anchored to the
|
||||
* first verbatim occurrence of `text`
|
||||
* in the article's plain content. Fails
|
||||
* gracefully if the snippet isn't found.
|
||||
*/
|
||||
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule, scopedGet } from '$lib/data/scope';
|
||||
import { tagMutations, useAllTags } from '@mana/shared-stores';
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { articlesStore } from './stores/articles.svelte';
|
||||
import { highlightsStore } from './stores/highlights.svelte';
|
||||
import { articleTagOps } from './stores/tags.svelte';
|
||||
import { toArticle } from './queries';
|
||||
import type { HighlightColor, LocalArticle, ArticleStatus } from './types';
|
||||
|
||||
const DEFAULT_LIST_LIMIT = 30;
|
||||
const MAX_LIST_LIMIT = 100;
|
||||
const MIN_HIGHLIGHT_TEXT = 10;
|
||||
const MAX_HIGHLIGHT_TEXT = 500;
|
||||
|
||||
export const articlesTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'list_articles',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description:
|
||||
'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).',
|
||||
required: false,
|
||||
enum: ['unread', 'reading', 'finished', 'archived', 'all'],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const limit = Math.min(
|
||||
Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1),
|
||||
MAX_LIST_LIMIT
|
||||
);
|
||||
const statusFilter = typeof params.status === 'string' ? params.status : '';
|
||||
const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : '';
|
||||
|
||||
const locals = await scopedForModule<LocalArticle, string>('articles', 'articles').toArray();
|
||||
const visible = locals.filter((a) => {
|
||||
if (a.deletedAt) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
if (statusFilter === '' || !statusFilter) return a.status !== 'archived';
|
||||
return a.status === statusFilter;
|
||||
});
|
||||
const decrypted = await decryptRecords('articles', visible);
|
||||
|
||||
const matches = query
|
||||
? decrypted.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(query) ||
|
||||
(a.author?.toLowerCase().includes(query) ?? false) ||
|
||||
(a.siteName?.toLowerCase().includes(query) ?? false)
|
||||
)
|
||||
: decrypted;
|
||||
|
||||
const rows = matches
|
||||
.sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? ''))
|
||||
.slice(0, limit)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
siteName: a.siteName,
|
||||
status: a.status,
|
||||
readingTimeMinutes: a.readingTimeMinutes,
|
||||
url: a.originalUrl,
|
||||
savedAt: a.savedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${rows.length} Artikel gefunden`,
|
||||
data: { articles: rows, total: matches.length },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'save_article',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
|
||||
parameters: [
|
||||
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 'string',
|
||||
description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const url = String(params.url ?? '').trim();
|
||||
if (!url) return { success: false, message: 'URL fehlt' };
|
||||
const { article, duplicate } = await articlesStore.saveFromUrl(url);
|
||||
return {
|
||||
success: true,
|
||||
message: duplicate
|
||||
? `Artikel bereits gespeichert: ${article.title}`
|
||||
: `Artikel gespeichert: ${article.title}`,
|
||||
data: { articleId: article.id, title: article.title, duplicate },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'archive_article',
|
||||
module: 'articles',
|
||||
description: 'Verschiebt einen Artikel ins Archiv.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const id = String(params.articleId ?? '').trim();
|
||||
if (!id) return { success: false, message: 'articleId fehlt' };
|
||||
const existing = await scopedGet<LocalArticle>('articles', id);
|
||||
if (!existing || existing.deletedAt) {
|
||||
return { success: false, message: `Kein Artikel mit id ${id}` };
|
||||
}
|
||||
await articlesStore.setStatus(id, 'archived' satisfies ArticleStatus);
|
||||
return { success: true, message: 'Artikel archiviert', data: { articleId: id } };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'tag_article',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'tagName',
|
||||
type: 'string',
|
||||
description: 'Tag-Name (z.B. "KI", "lesen bald")',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const id = String(params.articleId ?? '').trim();
|
||||
const rawName = String(params.tagName ?? '').trim();
|
||||
if (!id) return { success: false, message: 'articleId fehlt' };
|
||||
if (!rawName) return { success: false, message: 'tagName fehlt' };
|
||||
const name = rawName.slice(0, 60);
|
||||
|
||||
const existing = await scopedGet<LocalArticle>('articles', id);
|
||||
if (!existing || existing.deletedAt) {
|
||||
return { success: false, message: `Kein Artikel mit id ${id}` };
|
||||
}
|
||||
|
||||
// useAllTags().value works even outside a Svelte reactive scope —
|
||||
// it returns the current in-memory snapshot. Match by lower-case
|
||||
// name so "KI" and "ki" dedupe.
|
||||
const pool = useAllTags().value;
|
||||
const needle = name.toLowerCase();
|
||||
let tag = pool.find((t) => t.name.toLowerCase() === needle);
|
||||
if (!tag) {
|
||||
tag = await tagMutations.createTag({ name });
|
||||
}
|
||||
|
||||
await articleTagOps.addTag(id, tag.id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Tag „${tag.name}" gesetzt`,
|
||||
data: {
|
||||
articleId: id,
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
created: !pool.some((t) => t.id === tag!.id),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'add_article_highlight',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
description: 'Wörtliche Textstelle die markiert werden soll (10–500 Zeichen)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'string',
|
||||
description: 'Highlight-Farbe',
|
||||
required: false,
|
||||
enum: ['yellow', 'green', 'blue', 'pink'],
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'string',
|
||||
description: 'Optionale Notiz zum Highlight',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const id = String(params.articleId ?? '').trim();
|
||||
const text = String(params.text ?? '').trim();
|
||||
const color = (params.color as HighlightColor | undefined) ?? 'yellow';
|
||||
const note = typeof params.note === 'string' ? params.note.trim() || null : null;
|
||||
|
||||
if (!id) return { success: false, message: 'articleId fehlt' };
|
||||
if (text.length < MIN_HIGHLIGHT_TEXT) {
|
||||
return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` };
|
||||
}
|
||||
if (text.length > MAX_HIGHLIGHT_TEXT) {
|
||||
return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` };
|
||||
}
|
||||
|
||||
const existing = await scopedGet<LocalArticle>('articles', id);
|
||||
if (!existing || existing.deletedAt) {
|
||||
return { success: false, message: `Kein Artikel mit id ${id}` };
|
||||
}
|
||||
const [decrypted] = await decryptRecords('articles', [existing]);
|
||||
if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' };
|
||||
const article = toArticle(decrypted);
|
||||
|
||||
// Snap to the first verbatim occurrence of the snippet in the
|
||||
// Readability-extracted plain content. If the AI is hallucinating
|
||||
// (or the article was re-extracted and the text shifted) we bail
|
||||
// instead of persisting an orphan highlight.
|
||||
const startOffset = article.content.indexOf(text);
|
||||
if (startOffset < 0) {
|
||||
return { success: false, message: 'Textstelle nicht im Artikel gefunden' };
|
||||
}
|
||||
const endOffset = startOffset + text.length;
|
||||
const contextBefore =
|
||||
article.content.slice(Math.max(0, startOffset - 40), startOffset) || null;
|
||||
const contextAfter = article.content.slice(endOffset, endOffset + 40) || null;
|
||||
|
||||
const highlight = await highlightsStore.addHighlight({
|
||||
articleId: id,
|
||||
text,
|
||||
color,
|
||||
note,
|
||||
startOffset,
|
||||
endOffset,
|
||||
contextBefore,
|
||||
contextAfter,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'Highlight gesetzt',
|
||||
data: { highlightId: highlight.id, articleId: id },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -2,7 +2,21 @@
|
|||
|
||||
## Status (2026-04-21)
|
||||
|
||||
Proposed. Noch nichts gebaut.
|
||||
**M1 Skelett: DONE** (commit `3357e88a1`) — Modul registriert, Dexie v33, Encryption-Registry-Einträge (articles + articleHighlights verschlüsselt, articleTags auf Plaintext-Allowlist als FK-Junction ins globale Tag-System), App-Registry + orangefarbenes Icon, Route mountet, Empty-State ListView.
|
||||
|
||||
**M2 URL-Save + Reader: DONE** (commit `3357e88a1`) — `POST /api/v1/articles/extract` (ein Endpoint statt zwei — Client cached die Preview und vermeidet doppelten Server-Fetch), AddUrlForm mit scope-aware Dedupe, DetailView mit ReaderView (Serif/Sans, Light/Sepia/Dark, Size-Slider), auto-getrackter Reading-Progress mit Scroll-Restore.
|
||||
|
||||
**M3 Highlights: DONE** (commit `3357e88a1`) — TreeWalker-basierte Plain-Text-Offset-Resolution (`lib/offsets.ts`), `highlightsStore`, Floating `HighlightMenu` mit Create/Edit-Modi, `HighlightLayer`-Orchestrator der Overlays bei jedem Highlights- oder HTML-Change unwrap+re-applies (Range.surroundContents pro Text-Node-Slice). Vier Farben mit Dark-Mode-angepassten Alpha-Werten.
|
||||
|
||||
**M4 Tags + Filter: DONE** (commit `04293ed5e`) — `useArticleTagIds` / `useArticleTagMap` Live-Queries gegen `articleTagOps`. DetailView bekommt `<TagField>` aus `@mana/shared-ui` mit globalem Tag-Pool; `onChange` → `articleTagOps.setTags(id, ids)`. ListView: 6 Filter-Chips (Alle / Ungelesen / In Arbeit / Gelesen / Favoriten / Archiv) mit Live-Counts, `<TagChip>` auf jeder Karte, neue `.status-reading`-Pill, Favoriten-Stern.
|
||||
|
||||
**M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`.
|
||||
|
||||
**M6 AI-Tools: DONE** (commit pending) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab.
|
||||
|
||||
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
|
||||
|
||||
Nächster Schritt: M7 (Share-Target + Bookmarklet) oder M8 (HighlightsView + Stats + Dashboard-Widget).
|
||||
|
||||
## Ziel
|
||||
|
||||
|
|
|
|||
|
|
@ -384,6 +384,128 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
],
|
||||
},
|
||||
|
||||
// ── Articles (Pocket-style read-it-later) ───────────────
|
||||
{
|
||||
name: 'list_articles',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description:
|
||||
'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).',
|
||||
required: false,
|
||||
enum: ['unread', 'reading', 'finished', 'archived', 'all'],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Maximale Anzahl (Standard 30, max 100)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'save_article',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 'string',
|
||||
description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'archive_article',
|
||||
module: 'articles',
|
||||
description: 'Verschiebt einen Artikel ins Archiv.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tag_article',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'tagName',
|
||||
type: 'string',
|
||||
description: 'Tag-Name (z.B. "KI", "lesen bald")',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'add_article_highlight',
|
||||
module: 'articles',
|
||||
description:
|
||||
'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen — sonst wird der Call abgelehnt.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'articleId',
|
||||
type: 'string',
|
||||
description: 'ID des Artikels (aus list_articles)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
description: 'Wörtliche Textstelle die markiert werden soll (10–500 Zeichen)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'string',
|
||||
description: 'Highlight-Farbe',
|
||||
required: false,
|
||||
enum: ['yellow', 'green', 'blue', 'pink'],
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'string',
|
||||
description: 'Optionale Notiz zum Highlight',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── News-Research ─────────────────────────────────────────
|
||||
{
|
||||
name: 'research_news',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue