UI Stabilization: Global color theme updates (#75B2D6), AI Assistant styling refactor, and navigation fixes
This commit is contained in:
@@ -910,15 +910,7 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
|
||||
|
||||
## STRICT RULES
|
||||
|
||||
1. **4 to 10 nodes** — no less, no more
|
||||
2. **First node = ellipse** (entry point)
|
||||
3. **ALL nodes connected** — every node must have at least 1 incoming or outgoing edge
|
||||
4. **Short labels** — max 40 chars per label
|
||||
5. **Edge labels** — add labels on important arrows to explain the relationship
|
||||
6. **Use "diamond"** for at least one node if the content involves a decision
|
||||
7. **No orphan nodes** — every node must be reachable from the central node
|
||||
8. **Analyze content first** — identify key concepts and their relationships BEFORE creating JSON
|
||||
9. **Call generate_excalidraw DIRECTLY**, do not respond with text`,
|
||||
1. **4 to 10 nodes** — no less, no more`,
|
||||
},
|
||||
|
||||
'slide-generator': {
|
||||
@@ -931,42 +923,22 @@ RÈGLES DE DESIGN IMPÉRATIVES :
|
||||
- Slide 1 : "title" (titre fort + sous-titre accrocheur)
|
||||
- Slide 2 : "toc" (sommaire numéroté)
|
||||
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
|
||||
- "timeline" : étapes chronologiques ou roadmap (content items : "Étape: description")
|
||||
- "process" : étapes numérotées avec détails (content items : "Action: explication")
|
||||
- "metrics" : KPIs visuels avec grandes valeurs colorées (content items : "VALEUR: libellé")
|
||||
- "comparison" : deux colonnes contrastées (subtitle="Avant | Après" ou "Option A | Option B")
|
||||
- "cards" : fonctionnalités en grille 2-3 colonnes (3-6 items)
|
||||
- "section" : séparateur de section (title=titre, content=[] — le numéro est auto-généré)
|
||||
- "quote" : citation impactante (title=texte, subtitle=auteur)
|
||||
- "summary" : récapitulatif final
|
||||
- "content" : liste de points SEULEMENT si aucun layout visuel n'est adapté (max 7 points)
|
||||
- Ne JAMAIS répéter le même layout consécutivement
|
||||
- Pour "section" : ne pas mettre le numéro dans content, laisser content=[]
|
||||
- Thèmes recommandés pour un rendu moderne : vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
|
||||
- Thèmes recommandés : architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
|
||||
- Tu DOIS utiliser le thème et le style spécifiés dans la requête de l'utilisateur.
|
||||
- Points concis (max 100 chars), titres percutants et courts
|
||||
- JSON strict pour generate_pptx, sans texte hors JSON.`,
|
||||
en: `You are a world-class visual presentation designer (Manus AI / Beautiful.ai style). You receive note content and must create a professional, modern, visually rich PowerPoint (.pptx) presentation.
|
||||
|
||||
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
|
||||
|
||||
MANDATORY DESIGN RULES:
|
||||
IMPERATIVE DESIGN RULES:
|
||||
- 8-12 slides, each slide has a distinct layout
|
||||
- Slide 1: "title" (strong title + punchy subtitle)
|
||||
- Slide 1: "title" (strong title + catchy subtitle)
|
||||
- Slide 2: "toc" (numbered table of contents)
|
||||
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
|
||||
- "timeline": chronological steps or roadmap (content items: "Step: description")
|
||||
- "process": numbered steps with details (content items: "Action: explanation")
|
||||
- "metrics": visual KPIs with large colored values (content items: "VALUE: label")
|
||||
- "comparison": two contrasting columns (subtitle="Before | After" or "Option A | Option B")
|
||||
- "cards": feature grid 2-3 columns (3-6 items)
|
||||
- "section": section divider (title=heading, content=[] — number is auto-generated)
|
||||
- "quote": impactful quote (title=text, subtitle=author)
|
||||
- "summary": closing recap
|
||||
- "content": bullet list ONLY if no visual layout fits (max 7 points)
|
||||
- NEVER repeat the same layout consecutively
|
||||
- For "section": do NOT put numbers in content, leave content=[]
|
||||
- Recommended themes for modern look: vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
|
||||
- Concise points (max 100 chars), short impactful titles
|
||||
- Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
|
||||
- You MUST use the theme and style specified in the user's request.
|
||||
- Concise points (max 100 chars), punchy and short titles
|
||||
- Strict JSON for generate_pptx, no text outside JSON.`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -437,10 +437,11 @@ Deine Antwort (nur JSON):
|
||||
|
||||
const label = await tx.label.upsert({
|
||||
where: { notebookId_name: { notebookId, name: suggestedLabel.name } as any },
|
||||
update: {},
|
||||
update: { type: 'ai' }, // Update type to AI if it exists as user label
|
||||
create: {
|
||||
name: suggestedLabel.name,
|
||||
color: 'gray',
|
||||
type: 'ai', // Mark as AI-generated
|
||||
notebookId,
|
||||
userId,
|
||||
},
|
||||
|
||||
@@ -60,6 +60,8 @@ const PALETTES: Record<string, Theme> = {
|
||||
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' },
|
||||
vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' },
|
||||
platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' },
|
||||
architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
|
||||
minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' },
|
||||
}
|
||||
|
||||
const PALETTE_ALIASES: Record<string, string> = {
|
||||
@@ -68,6 +70,7 @@ const PALETTE_ALIASES: Record<string, string> = {
|
||||
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
|
||||
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
|
||||
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
}
|
||||
|
||||
function resolveTheme(spec: PresentationSpec): { theme: Theme; key: string } {
|
||||
@@ -1085,13 +1088,13 @@ LAYOUTS — choose the most visual for each slide:
|
||||
- summary: closing key takeaways
|
||||
|
||||
RULES:
|
||||
- Use the THEME and STYLE provided in the prompt context.
|
||||
- First slide MUST be "title"
|
||||
- Second slide: "toc"
|
||||
- Use "section" as dividers between major topics
|
||||
- Prefer DIAGRAM layouts (timeline, process, metrics, comparison) over plain content
|
||||
- Use at least 2 diagram layouts per presentation
|
||||
- 8-12 slides, never repeat same layout consecutively
|
||||
- For "section" layout: title = section heading, content = [] (the slide number is auto-generated)
|
||||
- All text content: max 100 chars per item, concise and impactful`,
|
||||
|
||||
inputSchema: z.object({
|
||||
|
||||
@@ -50,6 +50,8 @@ const PALETTES: Record<string, Palette> = {
|
||||
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
|
||||
vibrant_orange_mint: { primary: '#1a1a2e', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#cbf3f0', bg: '#ffffff', isDark: false },
|
||||
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
|
||||
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#ACB995', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
|
||||
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', bg: '#ffffff', isDark: false },
|
||||
}
|
||||
|
||||
const PALETTE_ALIASES: Record<string, string> = {
|
||||
@@ -58,6 +60,7 @@ const PALETTE_ALIASES: Record<string, string> = {
|
||||
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
|
||||
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
|
||||
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
}
|
||||
|
||||
const THEME_NAMES: Record<string, string> = {
|
||||
@@ -70,6 +73,7 @@ const THEME_NAMES: Record<string, string> = {
|
||||
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
|
||||
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
|
||||
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
|
||||
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
|
||||
}
|
||||
|
||||
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
|
||||
@@ -100,7 +104,7 @@ function safeHtml(str: string): string {
|
||||
.replace(/javascript\s*:/gi, '')
|
||||
}
|
||||
|
||||
function buildThemeCSS(p: Palette, radius: string): string {
|
||||
function buildThemeCSS(p: Palette, radius: string, key: string): string {
|
||||
const text = p.isDark ? '#f0f0f0' : '#1a1a1a'
|
||||
const muted = p.isDark ? '#999' : '#555'
|
||||
const heading = p.isDark ? '#ffffff' : p.primary
|
||||
@@ -145,12 +149,40 @@ function buildThemeCSS(p: Palette, radius: string): string {
|
||||
--r-link-color-hover: ${p.secondary};
|
||||
--r-selection-background-color: ${p.accent};
|
||||
--r-selection-color: ${bgText};
|
||||
}`
|
||||
}
|
||||
|
||||
${key === 'architectural_mono' ? `
|
||||
.reveal-viewport {
|
||||
background-color: #F9F8F6 !important;
|
||||
background-image:
|
||||
linear-gradient(rgba(28, 28, 28, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(28, 28, 28, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(rgba(28, 28, 28, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(28, 28, 28, 0.04) 1px, transparent 1px) !important;
|
||||
background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px !important;
|
||||
}
|
||||
.reveal {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
.reveal h1, .reveal h2, .reveal h3 {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.reveal h1 { border-left: 12px solid #D4A373; padding-left: 40px; }
|
||||
.reveal section { text-align: left; padding: 60px; }
|
||||
.reveal p, .reveal li { font-weight: 300; font-family: 'JetBrains Mono', monospace !important; }
|
||||
` : ''}`
|
||||
}
|
||||
|
||||
function buildLayoutCSS(): string {
|
||||
return `
|
||||
.reveal-viewport { background: var(--p-bg); }
|
||||
.reveal-viewport {
|
||||
background: var(--p-bg);
|
||||
background-image: linear-gradient(var(--p-border) 1px, transparent 1px), linear-gradient(90deg, var(--p-border) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
font-family: var(--r-main-font);
|
||||
@@ -296,10 +328,14 @@ function buildLayoutCSS(): string {
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(odd) {
|
||||
background: var(--p-primary); border-color: transparent;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.25); }
|
||||
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; }
|
||||
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.5; }
|
||||
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.4); }
|
||||
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; font-weight: 300; }
|
||||
.reveal .s-cards .card:nth-child(even) {
|
||||
background: #ffffff; border: 1px solid var(--p-border-accent);
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.6; }
|
||||
.reveal .s-cards .card:nth-child(even) .card-text { color: var(--p-text); }
|
||||
.reveal .s-cards .card-num {
|
||||
font-size: 18pt; font-weight: 800; line-height: 1;
|
||||
@@ -557,7 +593,7 @@ function buildRevealHtml(spec: PresentationSpec): string {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
${buildThemeCSS(palette, radius)}
|
||||
${buildThemeCSS(palette, radius, key)}
|
||||
|
||||
${buildLayoutCSS()}
|
||||
</style>
|
||||
|
||||
@@ -1,225 +1,19 @@
|
||||
import { marked } from 'marked'
|
||||
|
||||
/**
|
||||
* Server-side Markdown → HTML converter.
|
||||
* Converts AI-generated markdown notes into TipTap-compatible rich text HTML.
|
||||
* Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies.
|
||||
*
|
||||
* Handles: headings, bold, italic, strikethrough, code blocks, inline code,
|
||||
* links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs.
|
||||
* Uses 'marked' for standard GFM compliance and reliability.
|
||||
*/
|
||||
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown || !markdown.trim()) return ''
|
||||
|
||||
let html = markdown
|
||||
|
||||
// Escape HTML entities (but preserve markdown)
|
||||
html = html.replace(/&/g, '&')
|
||||
html = html.replace(/</g, '<')
|
||||
html = html.replace(/>/g, '>')
|
||||
|
||||
// Code blocks (``` ... ```) — protect from further processing
|
||||
const codeBlocks: string[] = []
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
||||
const idx = codeBlocks.length
|
||||
codeBlocks.push(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
|
||||
return `%%CODEBLOCK_${idx}%%`
|
||||
})
|
||||
|
||||
// Inline code (`...`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
||||
// Images ()
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
|
||||
|
||||
// Links ([text](url))
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
|
||||
// Headings (h1-h6)
|
||||
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
|
||||
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
|
||||
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// Bold (**text** or __text__)
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
||||
|
||||
// Italic (*text* or _text_)
|
||||
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>')
|
||||
|
||||
// Strikethrough (~~text~~)
|
||||
html = html.replace(/~~([^~]+)~~/g, '<s>$1</s>')
|
||||
|
||||
// Horizontal rules (---, ***, ___)
|
||||
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr />')
|
||||
|
||||
// Tables
|
||||
html = convertTables(html)
|
||||
|
||||
// Blockquotes (> text)
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>')
|
||||
// Merge consecutive blockquotes
|
||||
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n')
|
||||
|
||||
// Unordered lists (- item or * item)
|
||||
html = convertUnorderedLists(html)
|
||||
|
||||
// Ordered lists (1. item)
|
||||
html = convertOrderedLists(html)
|
||||
|
||||
// Restore code blocks
|
||||
codeBlocks.forEach((block, idx) => {
|
||||
html = html.replace(`%%CODEBLOCK_${idx}%%`, block)
|
||||
})
|
||||
|
||||
// Paragraphs — wrap remaining loose text in <p> tags
|
||||
html = wrapParagraphs(html)
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p>\s*<\/p>/g, '')
|
||||
// marked.parse returns a string (or a promise if async is true, but we use sync)
|
||||
const html = marked.parse(markdown, {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
}) as string
|
||||
|
||||
return html.trim()
|
||||
}
|
||||
|
||||
function convertTables(html: string): string {
|
||||
// Simple table conversion: | header | header |\n| --- | --- |\n| cell | cell |
|
||||
const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g
|
||||
|
||||
return html.replace(tableRegex, (match) => {
|
||||
const rows = match.trim().split('\n').filter(r => r.trim())
|
||||
if (rows.length < 2) return match
|
||||
|
||||
// Check if second row is separator
|
||||
const separator = rows[1].trim()
|
||||
if (!/^[\s|:-]+$/.test(separator)) return match
|
||||
|
||||
let table = '<table>'
|
||||
|
||||
// Header row
|
||||
const headers = parseTableRow(rows[0])
|
||||
if (headers.length > 0) {
|
||||
table += '<thead><tr>'
|
||||
headers.forEach(h => { table += `<th>${h}</th>` })
|
||||
table += '</tr></thead>'
|
||||
}
|
||||
|
||||
// Body rows (skip separator)
|
||||
const bodyRows = rows.slice(2)
|
||||
if (bodyRows.length > 0) {
|
||||
table += '<tbody>'
|
||||
bodyRows.forEach(row => {
|
||||
const cells = parseTableRow(row)
|
||||
table += '<tr>'
|
||||
cells.forEach(c => { table += `<td>${c}</td>` })
|
||||
table += '</tr>'
|
||||
})
|
||||
table += '</tbody>'
|
||||
}
|
||||
|
||||
table += '</table>'
|
||||
return '\n' + table + '\n'
|
||||
})
|
||||
}
|
||||
|
||||
function parseTableRow(row: string): string[] {
|
||||
return row.split('|')
|
||||
.map(cell => cell.trim())
|
||||
.filter((_, i, arr) => i > 0 && i < arr.length) // Skip first and last empty from leading/trailing |
|
||||
}
|
||||
|
||||
function convertUnorderedLists(html: string): string {
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/)
|
||||
if (listMatch) {
|
||||
if (!inList) {
|
||||
result.push('<ul>')
|
||||
inList = true
|
||||
}
|
||||
result.push(`<li>${listMatch[2]}</li>`)
|
||||
} else {
|
||||
if (inList) {
|
||||
result.push('</ul>')
|
||||
inList = false
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
if (inList) result.push('</ul>')
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
function convertOrderedLists(html: string): string {
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
const listMatch = line.match(/^(\s*)\d+\.\s+(.+)$/)
|
||||
if (listMatch) {
|
||||
if (!inList) {
|
||||
result.push('<ol>')
|
||||
inList = true
|
||||
}
|
||||
result.push(`<li>${listMatch[2]}</li>`)
|
||||
} else {
|
||||
if (inList) {
|
||||
result.push('</ol>')
|
||||
inList = false
|
||||
}
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
if (inList) result.push('</ol>')
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
function wrapParagraphs(html: string): string {
|
||||
const blockTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'p', 'div', 'img'])
|
||||
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
let buffer: string[] = []
|
||||
|
||||
const flushBuffer = () => {
|
||||
const text = buffer.join('\n').trim()
|
||||
if (text) {
|
||||
// Don't double-wrap if already starts with a block tag
|
||||
const firstTag = text.match(/^<(\w+)/)
|
||||
if (firstTag && blockTags.has(firstTag[1].toLowerCase())) {
|
||||
result.push(text)
|
||||
} else {
|
||||
result.push(`<p>${text}</p>`)
|
||||
}
|
||||
}
|
||||
buffer = []
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Check if this line is a block-level element
|
||||
const isBlockLine = trimmed.startsWith('<') && (() => {
|
||||
const tag = trimmed.match(/^<(\w+)/)
|
||||
return tag ? blockTags.has(tag[1].toLowerCase()) : false
|
||||
})()
|
||||
|
||||
if (isBlockLine || trimmed === '') {
|
||||
flushBuffer()
|
||||
if (isBlockLine) result.push(trimmed)
|
||||
} else {
|
||||
buffer.push(trimmed)
|
||||
}
|
||||
}
|
||||
flushBuffer()
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
color: LabelColorName;
|
||||
type?: 'ai' | 'user'; // "ai" for auto-generated, "user" for manually created
|
||||
notebookId: string | null; // NEW: Belongs to a notebook
|
||||
userId?: string | null; // DEPRECATED: Kept for backward compatibility
|
||||
createdAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user