entari-plugin-hyw 3.5.0rc1__py3-none-any.whl → 3.5.0rc2__py3-none-any.whl

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.

Potentially problematic release.


This version of entari-plugin-hyw might be problematic. Click here for more details.

Files changed (32) hide show
  1. entari_plugin_hyw/__init__.py +77 -82
  2. entari_plugin_hyw/assets/card-dist/index.html +360 -99
  3. entari_plugin_hyw/card-ui/src/App.vue +246 -52
  4. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +122 -67
  5. entari_plugin_hyw/card-ui/src/components/StageCard.vue +46 -26
  6. entari_plugin_hyw/card-ui/src/test_regex.js +103 -0
  7. entari_plugin_hyw/card-ui/src/types.ts +1 -0
  8. entari_plugin_hyw/{core/history.py → history.py} +25 -1
  9. entari_plugin_hyw/image_cache.py +283 -0
  10. entari_plugin_hyw/{core/pipeline.py → pipeline.py} +102 -27
  11. entari_plugin_hyw/{utils/prompts.py → prompts.py} +7 -24
  12. entari_plugin_hyw/render_vue.py +314 -0
  13. entari_plugin_hyw/{utils/search.py → search.py} +227 -10
  14. {entari_plugin_hyw-3.5.0rc1.dist-info → entari_plugin_hyw-3.5.0rc2.dist-info}/METADATA +1 -1
  15. {entari_plugin_hyw-3.5.0rc1.dist-info → entari_plugin_hyw-3.5.0rc2.dist-info}/RECORD +18 -29
  16. entari_plugin_hyw/core/__init__.py +0 -0
  17. entari_plugin_hyw/core/config.py +0 -35
  18. entari_plugin_hyw/core/hyw.py +0 -48
  19. entari_plugin_hyw/core/render_vue.py +0 -255
  20. entari_plugin_hyw/test_output/render_0.jpg +0 -0
  21. entari_plugin_hyw/test_output/render_1.jpg +0 -0
  22. entari_plugin_hyw/test_output/render_2.jpg +0 -0
  23. entari_plugin_hyw/test_output/render_3.jpg +0 -0
  24. entari_plugin_hyw/test_output/render_4.jpg +0 -0
  25. entari_plugin_hyw/tests/ui_test_output.jpg +0 -0
  26. entari_plugin_hyw/tests/verify_ui.py +0 -139
  27. entari_plugin_hyw/utils/__init__.py +0 -2
  28. entari_plugin_hyw/utils/browser.py +0 -40
  29. entari_plugin_hyw/utils/playwright_tool.py +0 -36
  30. /entari_plugin_hyw/{utils/misc.py → misc.py} +0 -0
  31. {entari_plugin_hyw-3.5.0rc1.dist-info → entari_plugin_hyw-3.5.0rc2.dist-info}/WHEEL +0 -0
  32. {entari_plugin_hyw-3.5.0rc1.dist-info → entari_plugin_hyw-3.5.0rc2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+
3
4
  import { marked, type Tokens } from 'marked'
5
+ import katex from 'katex'
6
+ import 'katex/dist/katex.min.css'
4
7
  import hljs from 'highlight.js/lib/core'
5
8
  // Import only common languages to reduce bundle size
6
9
  import python from 'highlight.js/lib/languages/python'
@@ -77,45 +80,29 @@ renderer.code = ({ text, lang }: Tokens.Code): string => {
77
80
  highlighted = hljs.highlightAuto(text).value
78
81
  }
79
82
 
83
+ // Add line numbers to code
84
+ const addLineNumbers = (code: string): string => {
85
+ const lines = code.split('\n')
86
+ return lines.map((line, i) =>
87
+ `<span class="code-line"><span class="line-number">${i + 1}</span><span class="line-content">${line}</span></span>`
88
+ ).join('')
89
+ }
90
+
91
+ const highlightedWithLines = addLineNumbers(highlighted)
92
+
80
93
  // Bare mode: just the code, no window decoration
81
94
  if (props.bare) {
82
- return `<pre class="!mt-0 !mb-0 !rounded-none !bg-gray-50 !p-4 overflow-x-auto border-b border-gray-100"><code class="hljs language-${language} text-[13px] leading-relaxed font-mono">${highlighted}</code></pre>`
95
+ return `<pre class="!mt-0 !mb-0 !rounded-none !bg-gray-50 !p-0 border-b border-gray-100 code-with-lines"><code class="hljs language-${language} text-[17.5px] leading-snug font-mono">${highlightedWithLines}</code></pre>`
83
96
  }
84
97
 
85
98
  // Dynamic Icon mapping
86
- const getLangIcon = (l: string) => {
87
- const map: Record<string, { icon: string, color: string }> = {
88
- 'python': { icon: 'mdi:language-python', color: 'text-blue-500' },
89
- 'javascript': { icon: 'mdi:language-javascript', color: 'text-yellow-500' },
90
- 'js': { icon: 'mdi:language-javascript', color: 'text-yellow-500' },
91
- 'typescript': { icon: 'mdi:language-typescript', color: 'text-blue-600' },
92
- 'ts': { icon: 'mdi:language-typescript', color: 'text-blue-600' },
93
- 'bash': { icon: 'mdi:terminal', color: 'text-green-500' },
94
- 'sh': { icon: 'mdi:terminal', color: 'text-green-500' },
95
- 'shell': { icon: 'mdi:terminal', color: 'text-green-500' },
96
- 'json': { icon: 'mdi:code-json', color: 'text-yellow-600' },
97
- 'html': { icon: 'mdi:language-html5', color: 'text-orange-500' },
98
- 'css': { icon: 'mdi:language-css3', color: 'text-blue-500' },
99
- 'yaml': { icon: 'mdi:file-cog', color: 'text-purple-500' },
100
- 'sql': { icon: 'mdi:database', color: 'text-red-500' }
101
- }
102
- return map[l] || { icon: 'mdi:code-braces', color: 'text-red-500' }
103
- }
104
- const langInfo = getLangIcon(language)
99
+
105
100
 
106
101
  return `
107
102
  <div class="my-6 space-y-1 group">
108
- <div class="flex items-center justify-between px-3 py-1.5 bg-gray-50 ">
109
- <div class="flex items-center gap-2">
110
- <Icon icon="${langInfo.icon}" class="${langInfo.color} text-sm" />
111
- <span class="text-[10px] font-black text-gray-700 uppercase tracking-widest">${language}</span>
112
- </div>
113
- <div class="text-gray-500 text-[9px] font-mono tracking-tighter tabular-nums">
114
- Source Code
115
- </div>
116
- </div>
103
+ <div class="h-4 w-24 ml-auto" style="background-color: var(--theme-color);"></div>
117
104
  <div class="">
118
- <pre class="!mt-0 !mb-0 !rounded-none !bg-white !p-4 overflow-x-auto"><code class="hljs language-${language} text-[13px] leading-relaxed font-mono">${highlighted}</code></pre>
105
+ <pre class="!mt-0 !mb-0 !rounded-none !bg-white !p-0 code-with-lines"><code class="hljs language-${language} text-[17.5px] leading-snug font-mono">${highlightedWithLines}</code></pre>
119
106
  </div>
120
107
  </div>
121
108
  `
@@ -123,6 +110,19 @@ renderer.code = ({ text, lang }: Tokens.Code): string => {
123
110
 
124
111
  marked.use({ renderer })
125
112
 
113
+ // Render LaTeX math with KaTeX
114
+ function renderMath(tex: string, displayMode: boolean): string {
115
+ try {
116
+ return katex.renderToString(tex, {
117
+ displayMode,
118
+ throwOnError: false,
119
+ strict: false,
120
+ })
121
+ } catch {
122
+ return `<code>${tex}</code>`
123
+ }
124
+ }
125
+
126
126
  // Process markdown and convert citations to badges
127
127
  const processedHtml = computed(() => {
128
128
  let md = props.markdown || ''
@@ -130,23 +130,40 @@ const processedHtml = computed(() => {
130
130
  // Remove References section at end
131
131
  md = md.replace(/(?:^|\n)\s*(?:#{1,3}|\*\*)\s*(?:References|Citations|Sources)[\s\S]*$/i, '')
132
132
 
133
+ // Protect math blocks from markdown parsing by replacing with placeholders
134
+ const mathBlocks: { placeholder: string; html: string }[] = []
135
+ let mathIndex = 0
136
+
137
+ // Block math: $$...$$ or \[...\]
138
+ md = md.replace(/\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]/g, (_, tex1, tex2) => {
139
+ const tex = tex1 || tex2
140
+ const placeholder = `%%MATH_BLOCK_${mathIndex++}%%`
141
+ mathBlocks.push({ placeholder, html: `<div class="my-4 overflow-x-auto">${renderMath(tex.trim(), true)}</div>` })
142
+ return placeholder
143
+ })
144
+
145
+ // Inline math: $...$ or \(...\) (but not $$)
146
+ md = md.replace(/\$([^\$\n]+?)\$|\\\((.+?)\\\)/g, (_, tex1, tex2) => {
147
+ const tex = tex1 || tex2
148
+ const placeholder = `%%MATH_INLINE_${mathIndex++}%%`
149
+ mathBlocks.push({ placeholder, html: renderMath(tex.trim(), false) })
150
+ return placeholder
151
+ })
152
+
133
153
  // Convert markdown to HTML
134
154
  let html = marked.parse(md) as string
135
155
 
156
+ // Restore math blocks
157
+ for (const { placeholder, html: mathHtml } of mathBlocks) {
158
+ html = html.replace(placeholder, mathHtml)
159
+ }
160
+
136
161
  // Render <summary> tags as technical highlight blocks
137
162
  html = html.replace(/<summary>([\s\S]*?)<\/summary>/g, (_, content) => {
138
163
  return `
139
- <div class="my-8 group">
140
- <div class="flex items-center justify-between px-3 py-1.5 bg-gray-50 ">
141
- <div class="flex items-center gap-2">
142
- <Icon icon="mdi:lightning-bolt" class="text-red-500 text-sm" />
143
- <span class="text-[10px] font-black text-gray-700 uppercase tracking-widest">Summary</span>
144
- </div>
145
- <div class="text-gray-500 text-[9px] font-mono tracking-tighter tabular-nums">
146
- Insight
147
- </div>
148
- </div>
149
- <div class="p-5 text-[15px] leading-relaxed text-gray-800 font-medium bg-white ">
164
+ <div class="my-8 group shadow-sm shadow-black/10">
165
+ <div class="h-4 w-full" style="background-color: var(--theme-color);"></div>
166
+ <div class="p-6 text-[19px] leading-relaxed text-gray-700 font-medium bg-white">
150
167
  ${content}
151
168
  </div>
152
169
  </div>
@@ -177,7 +194,7 @@ const processedHtml = computed(() => {
177
194
  })
178
195
  })
179
196
 
180
- const containerClass = "w-full bg-white text-[12px] select-text";
197
+ const containerClass = "w-full bg-white text-[16.5px] select-text";
181
198
 
182
199
  let gridHtml = `<div class="${containerClass}">`
183
200
 
@@ -186,7 +203,7 @@ const processedHtml = computed(() => {
186
203
  allRows.forEach((row: any[], rowIndex: number) => {
187
204
  const isHeader = rowIndex === 0;
188
205
  const rowBg = isHeader
189
- ? 'bg-white text-gray-900 font-black uppercase tracking-tight'
206
+ ? 'bg-white text-gray-800 font-black uppercase tracking-tight'
190
207
  : (rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50/30');
191
208
  const borderB = rowIndex < allRows.length - 1 ? 'border-b border-gray-200' : '';
192
209
 
@@ -196,7 +213,7 @@ const processedHtml = computed(() => {
196
213
  const justify = cell.align === 'center' ? 'justify-center text-center' : (cell.align === 'right' ? 'justify-end text-right' : 'justify-start');
197
214
  const borderClass = colIndex === row.length - 1 ? '' : 'border-r border-gray-100';
198
215
 
199
- gridHtml += `<div class="flex-1 py-2.5 px-3 min-w-0 break-words flex items-center leading-tight ${justify} ${borderClass}">
216
+ gridHtml += `<div class="flex-1 py-3.5 px-4 min-w-0 break-words flex items-center leading-tight ${justify} ${borderClass}">
200
217
  <span>${cell.html}</span>
201
218
  </div>`;
202
219
  });
@@ -205,22 +222,27 @@ const processedHtml = computed(() => {
205
222
  gridHtml += `</div>`;
206
223
 
207
224
  if (props.bare) {
208
- return `<div class="overflow-x-auto border-b border-gray-200">${gridHtml}</div>`
225
+ return `<div class="border-b border-gray-200">${gridHtml}</div>`
209
226
  }
210
227
 
211
228
  return `
212
229
  <div class="my-6 group">
213
- <div class="overflow-x-auto bg-white p-0 border-t border-gray-100">
230
+ <div class="bg-white p-0 border-t border-gray-100">
214
231
  ${gridHtml}
215
232
  </div>
216
233
  </div>
217
234
  `
218
235
  })
219
236
 
220
- // Convert [N] citations to rectangular sharp badges
237
+ // Convert [N] citations to small square badges with shadow
221
238
  html = html.replace(/\[(\d+)\]/g, (_, n) => {
222
239
  const num = parseInt(n)
223
- return `<span class="relative -top-1.5 text-[10px] font-bold text-blue-600 mx-0.5 cursor-default select-none transition-all">${num}</span>`
240
+ return `<sup class="inline-flex items-center justify-center w-[15px] h-[15px] text-[10px] font-bold cursor-default select-none ml-1 align-middle" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 2px 0 rgba(0,0,0,0.15)">${num}</sup>`
241
+ })
242
+
243
+ // Style <u> underline tags with theme-colored solid underline
244
+ html = html.replace(/<u>([^<]*)<\/u>/g, (_, content) => {
245
+ return `<span class="underline decoration-[3px] underline-offset-[6px]" style="text-decoration-color: var(--theme-color)">${content}</span>`
224
246
  })
225
247
 
226
248
  return html
@@ -229,14 +251,15 @@ const processedHtml = computed(() => {
229
251
 
230
252
  <template>
231
253
  <div ref="contentRef"
232
- class="prose prose-slate max-w-none
233
- prose-headings:text-gray-900 prose-headings:font-black prose-headings:mb-2 prose-headings:mt-6 prose-headings:tracking-tight
234
- prose-p:text-gray-800 prose-p:leading-relaxed prose-p:my-3
254
+ class="prose prose-slate max-w-none prose-lg
255
+ prose-headings:text-gray-800 prose-headings:font-bold prose-headings:mb-3 prose-headings:mt-8 prose-headings:tracking-tight
256
+ prose-p:text-gray-800 prose-p:leading-7 prose-p:my-4 prose-p:text-[20px] prose-li:text-[20px] prose-li:text-gray-800
235
257
  prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
236
- prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded-none prose-code:text-[0.9em] prose-code:font-mono prose-code:text-gray-900
258
+ prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-none prose-code:text-[0.85em] prose-code:font-mono prose-code:text-gray-800
237
259
  prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-200 prose-pre:rounded-none prose-pre:p-0
238
- prose-img:rounded-none prose-img:my-6 prose-img:max-h-[400px] prose-img:w-auto prose-img:object-contain prose-img:border prose-img:border-gray-200 prose-img:
239
- prose-ol:list-decimal prose-ol:pl-4 prose-ol:list-outside
260
+ prose-img:rounded-none prose-img:my-6 prose-img:max-h-[400px] prose-img:w-auto prose-img:object-contain prose-img:border prose-img:border-gray-200
261
+ prose-ol:list-decimal prose-ol:pl-7 prose-ol:list-outside prose-ol:my-5
262
+ prose-li:my-2.5 prose-li:leading-7
240
263
  [&>*:first-child]:!mt-0"
241
264
  v-html="processedHtml">
242
265
  </div>
@@ -253,15 +276,15 @@ const processedHtml = computed(() => {
253
276
  .prose ul {
254
277
  list-style: none !important;
255
278
  padding-left: 0.25rem !important;
256
- margin-top: 0.75rem !important;
257
- margin-bottom: 0.75rem !important;
279
+ margin-top: 1rem !important;
280
+ margin-bottom: 1rem !important;
258
281
  }
259
282
 
260
283
  .prose ul > li {
261
284
  position: relative !important;
262
285
  padding-left: 1.5rem !important;
263
- margin-top: 0.5rem !important;
264
- margin-bottom: 0.5rem !important;
286
+ margin-top: 0.75rem !important;
287
+ margin-bottom: 0.75rem !important;
265
288
  line-height: 1.6 !important;
266
289
  }
267
290
 
@@ -270,9 +293,9 @@ const processedHtml = computed(() => {
270
293
  position: absolute !important;
271
294
  left: 0 !important;
272
295
  top: 0.6em !important;
273
- width: 6px !important;
274
- height: 6px !important;
275
- background-color: #ef4444 !important; /* Red-500 */
296
+ width: 8px !important;
297
+ height: 8px !important;
298
+ background-color: var(--theme-color, #ef4444) !important; /* Theme color */
276
299
  border-radius: 0 !important;
277
300
  }
278
301
 
@@ -290,9 +313,9 @@ const processedHtml = computed(() => {
290
313
  }
291
314
 
292
315
  .prose ul ul > li::before {
293
- width: 5px !important;
294
- height: 5px !important;
295
- background-color: #ef4444 !important; /* Red-500 - same as parent, slightly smaller */
316
+ width: 6px !important;
317
+ height: 6px !important;
318
+ background-color: var(--theme-color, #ef4444) !important; /* Theme color - same as parent, slightly smaller */
296
319
  top: 0.65em !important;
297
320
  }
298
321
 
@@ -312,8 +335,8 @@ const processedHtml = computed(() => {
312
335
  left: 0 !important;
313
336
  top: 0 !important;
314
337
  bottom: 0 !important;
315
- width: 3px !important;
316
- background-color: #ef4444 !important; /* Red-500 - thick line */
338
+ width: 5px !important;
339
+ background-color: var(--theme-color, #ef4444) !important; /* Theme color - thick line */
317
340
  }
318
341
 
319
342
 
@@ -321,10 +344,42 @@ const processedHtml = computed(() => {
321
344
  /* Ensure images don't have artifacts */
322
345
  .prose img {
323
346
  display: block;
324
- margin-left: auto;
347
+ margin-left: 0;
325
348
  margin-right: auto;
326
349
  }
327
350
  .prose pre {
328
351
  border: none !important;
329
352
  }
353
+
354
+ /* Code line numbers - Modern minimalist style */
355
+ .code-with-lines code {
356
+ display: block;
357
+ padding: 1.25em 0;
358
+ background: white;
359
+ }
360
+ .code-with-lines .code-line {
361
+ display: flex;
362
+ align-items: stretch;
363
+ }
364
+ .code-with-lines .line-number {
365
+ flex-shrink: 0;
366
+ width: 36px;
367
+ padding: 0.1em 8px 0.1em 4px;
368
+ text-align: right;
369
+ color: var(--text-muted, #9ca3af);
370
+ background: white;
371
+ border-right: 1px solid #e5e7eb;
372
+ user-select: none;
373
+ font-size: 11px;
374
+ display: flex;
375
+ align-items: flex-start;
376
+ justify-content: flex-end;
377
+ }
378
+ .code-with-lines .line-content {
379
+ flex: 1;
380
+ padding: 0.1em 1.25em 0.1em 1em;
381
+ white-space: pre-wrap;
382
+ word-break: break-all;
383
+ background: white;
384
+ }
330
385
  </style>
@@ -14,11 +14,15 @@ const props = defineProps<{
14
14
  isFirst?: boolean
15
15
  isLast?: boolean
16
16
  prevStageName?: string
17
+ refOffset?: number
17
18
  }>()
18
19
 
19
20
  function getDomain(url: string): string {
20
21
  try {
21
- return new URL(url).hostname.replace('www.', '')
22
+ const urlObj = new URL(url)
23
+ const hostname = urlObj.hostname.replace('www.', '')
24
+ const pathname = urlObj.pathname === '/' ? '' : urlObj.pathname
25
+ return hostname + pathname
22
26
  } catch {
23
27
  return url
24
28
  }
@@ -56,12 +60,12 @@ function getStageTheme(name?: string) {
56
60
  }
57
61
 
58
62
  const themes: Record<string, any> = {
59
- 'search': { color: 'text-blue-600', bg: 'bg-blue-50', line: 'bg-red-300', iconBg: 'bg-blue-100/50', dotBg: 'bg-red-500', icon: 'mdi:magnify' },
60
- 'crawler': { color: 'text-orange-600', bg: 'bg-orange-50', line: 'bg-red-300', iconBg: 'bg-orange-100/50', dotBg: 'bg-red-500', icon: 'mdi:web' },
61
- 'agent': { color: 'text-purple-600', bg: 'bg-purple-50', line: 'bg-red-300', iconBg: 'bg-white/80', dotBg: 'bg-red-500', icon: 'mdi:robot' },
62
- 'instruct': { color: 'text-red-600', bg: 'bg-red-50', line: 'bg-red-300', iconBg: 'bg-white/80', dotBg: 'bg-red-500', icon: 'mdi:lightning-bolt' },
63
- 'vision': { color: 'text-green-600', bg: 'bg-green-50', line: 'bg-red-300', iconBg: 'bg-green-100/50', dotBg: 'bg-red-500', icon: 'mdi:eye' },
64
- 'default': { color: 'text-gray-600', bg: 'bg-gray-50', line: 'bg-red-300', iconBg: 'bg-gray-100/50', dotBg: 'bg-red-500', icon: 'mdi:circle' }
63
+ 'search': { color: 'text-blue-600', bg: 'bg-blue-50', iconBg: 'bg-blue-100/50', icon: 'mdi:magnify' },
64
+ 'crawler': { color: 'text-orange-600', bg: 'bg-orange-50', iconBg: 'bg-orange-100/50', icon: 'mdi:web' },
65
+ 'agent': { color: 'text-purple-600', bg: 'bg-purple-50', iconBg: 'bg-white/80', icon: 'mdi:robot' },
66
+ 'instruct': { color: 'text-red-600', bg: 'bg-red-50', iconBg: 'bg-white/80', icon: 'mdi:lightning-bolt' },
67
+ 'vision': { color: 'text-purple-600', bg: 'bg-purple-50', iconBg: 'bg-white/80', icon: 'mdi:eye' },
68
+ 'default': { color: 'text-gray-600', bg: 'bg-gray-50', iconBg: 'bg-gray-100/50', icon: 'mdi:circle' }
65
69
  }
66
70
 
67
71
  function getIcon(name: string): string {
@@ -104,18 +108,18 @@ function getModelLogo(model: string): string | undefined {
104
108
  <div class="rounded-none overflow-hidden bg-white">
105
109
 
106
110
  <!-- Header -->
107
- <div :class="['bg-white px-3 py-1.5 flex items-center justify-between gap-2']">
108
- <div class="flex items-center gap-2">
109
- <div :class="['w-6 h-6 flex items-center justify-center shrink-0 overflow-hidden border border-gray-100', getStageTheme(stage.name).iconBg, getStageTheme(stage.name).color]">
110
- <img v-if="getModelLogo(stage.model)" :src="getModelLogo(stage.model)" class="w-4 h-4 object-contain" />
111
- <Icon v-else :icon="getIcon(stage.name)" class="text-xs" />
111
+ <div :class="['bg-white px-4 py-2.5 flex items-center justify-between gap-3']">
112
+ <div class="flex items-center gap-3">
113
+ <div :class="['w-8 h-8 flex items-center justify-center shrink-0 overflow-hidden border border-gray-100', getStageTheme(stage.name).iconBg, getStageTheme(stage.name).color]">
114
+ <img v-if="getModelLogo(stage.model)" :src="getModelLogo(stage.model)" class="w-5 h-5 object-contain" />
115
+ <Icon v-else :icon="getIcon(stage.name)" class="text-lg" />
112
116
  </div>
113
117
  <div class="flex flex-col">
114
- <span class="font-black text-xs text-gray-900 uppercase tracking-tight">{{ stage.name }}</span>
115
- <span class="text-[10px] font-mono text-gray-400 tabular-nums tracking-tighter">{{ getModelShort(stage.model) }}</span>
118
+ <span class="font-black text-[18px] text-gray-800 uppercase tracking-tight">{{ stage.name }}</span>
119
+ <span class="text-[15.5px] font-mono tabular-nums tracking-tighter" style="color: var(--text-muted)">{{ getModelShort(stage.model) }}</span>
116
120
  </div>
117
121
  </div>
118
- <div v-if="stage.time > 0 || stage.cost > 0" class="text-[10px] text-gray-400 font-mono flex items-center justify-end gap-2 leading-tight min-w-[120px]">
122
+ <div v-if="stage.time > 0 || stage.cost > 0" class="text-[15.5px] font-mono flex items-center justify-end gap-2 leading-tight min-w-[120px]" style="color: var(--text-muted)">
119
123
  <span v-if="stage.cost > 0">{{ formatCost(stage.cost) }}</span>
120
124
  <span v-if="stage.time > 0 && stage.cost > 0" class="text-gray-300">·</span>
121
125
  <span v-if="stage.time > 0">{{ formatTime(stage.time) }}</span>
@@ -123,39 +127,55 @@ function getModelLogo(model: string): string | undefined {
123
127
  </div>
124
128
 
125
129
 
126
- <div v-if="stage.references?.length || stage.image_references?.length" class="bg-white pl-8 relative">
130
+ <div v-if="stage.references?.length || stage.image_references?.length || stage.crawled_pages?.length" class="bg-white pl-11 relative">
127
131
  <div v-if="stage.references?.length" class="divide-y divide-gray-50 relative z-10">
128
132
  <a v-for="(ref, idx) in stage.references" :key="idx"
129
133
  :href="ref.url" target="_blank"
130
- class="flex items-start gap-2 pr-3 py-2 hover:bg-gray-50 transition-colors group">
134
+ class="flex items-start gap-3 pr-3 py-3 hover:bg-gray-50 transition-colors group">
131
135
  <!-- Favicon - Aligned with Title -->
132
- <img :src="getFavicon(ref.url)" class="w-3 h-3 rounded-none shrink-0 object-contain mt-[3px]">
136
+ <img :src="getFavicon(ref.url)" class="w-4 h-4 rounded-none shrink-0 object-contain mt-[4px]">
133
137
 
134
138
  <!-- Content: Title and Domain -->
135
139
  <div class="flex-1 min-w-0 flex flex-col">
136
140
  <div class="flex items-center gap-2">
137
- <span class="flex-1 text-xs font-bold text-gray-800 truncate leading-tight tracking-tight">{{ ref.title }}</span>
138
- <!-- Neutralized Citation Design -->
139
- <span class="shrink-0 text-[10px] font-bold text-blue-600 flex items-center justify-center">{{ idx + 1 }}</span>
141
+ <span class="flex-1 text-[18px] font-bold text-gray-700 truncate leading-tight tracking-tight">{{ ref.title }}</span>
142
+ <!-- Square Badge with Shadow -->
143
+ <span class="shrink-0 w-[18px] h-[18px] text-[11px] font-bold flex items-center justify-center" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 3px 0 rgba(0,0,0,0.15)">{{ (refOffset || 0) + idx + 1 }}</span>
140
144
  </div>
141
- <div class="text-[10px] font-mono text-gray-400 truncate mt-0.5 tracking-tighter">{{ getDomain(ref.url) }}</div>
145
+ <div class="text-[15.5px] font-mono truncate mt-0.5 tracking-tighter" style="color: var(--text-muted)">{{ getDomain(ref.url) }}</div>
142
146
  </div>
143
147
  </a>
144
148
  </div>
145
149
 
146
150
  <!-- Image Search Results -->
147
- <div v-if="stage.image_references?.length" class="pr-2 py-2 relative z-10">
148
- <div class="grid grid-cols-3 gap-1">
151
+ <div v-if="stage.image_references?.length" class="pr-3 py-3 relative z-10">
152
+ <div class="grid grid-cols-2 gap-2 items-start">
149
153
  <a v-for="(img, idx) in stage.image_references" :key="idx"
150
154
  v-show="!failedImages[img.url]"
151
155
  :href="img.url" target="_blank"
152
- class="relative aspect-square overflow-hidden bg-white border border-gray-200 transition-colors">
156
+ class="relative overflow-hidden transition-all hover:opacity-90 group">
153
157
  <img :src="img.thumbnail || img.url"
154
158
  @error="handleImageError(img.url)"
155
- class="w-full h-full object-cover">
159
+ class="w-full h-auto block group-hover:scale-[1.02] transition-transform">
156
160
  </a>
157
161
  </div>
158
162
  </div>
163
+
164
+ <div v-if="stage.crawled_pages?.length" class="divide-y divide-gray-50 relative z-10">
165
+ <a v-for="(page, idx) in stage.crawled_pages" :key="idx"
166
+ :href="page.url" target="_blank"
167
+ class="flex items-start gap-3 pr-3 py-3 hover:bg-gray-50 transition-colors group">
168
+ <img :src="getFavicon(page.url)" class="w-4 h-4 rounded-none shrink-0 object-contain mt-[4px]">
169
+ <div class="flex-1 min-w-0 flex flex-col">
170
+ <div class="flex items-center gap-2">
171
+ <span class="flex-1 text-[18px] font-bold text-gray-700 truncate leading-tight tracking-tight">{{ page.title }}</span>
172
+ <!-- Square Badge with Shadow -->
173
+ <span class="shrink-0 w-[18px] h-[18px] text-[11px] font-bold flex items-center justify-center" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 3px 0 rgba(0,0,0,0.15)">{{ (refOffset || 0) + (stage.references?.length || 0) + idx + 1 }}</span>
174
+ </div>
175
+ <div class="text-[15.5px] font-mono truncate mt-0.5 tracking-tighter" style="color: var(--text-muted)">{{ getDomain(page.url) }}</div>
176
+ </div>
177
+ </a>
178
+ </div>
159
179
  </div>
160
180
  </div>
161
181
  </div>
@@ -0,0 +1,103 @@
1
+
2
+ const stripPrefixBeforeH1 = (text) => {
3
+ const h1Match = text.match(/^#\s+/m)
4
+ if (h1Match && h1Match.index !== undefined) {
5
+ return text.substring(h1Match.index)
6
+ }
7
+ return text
8
+ }
9
+
10
+ const dedent = (text) => {
11
+ const lines = text.split('\n')
12
+ // Find minimum indentation of non-empty lines
13
+ let minIndent = Infinity
14
+ for (const line of lines) {
15
+ if (line.trim().length === 0) continue
16
+ const leadingSpace = line.match(/^\s*/)?.[0].length || 0
17
+ if (leadingSpace < minIndent) minIndent = leadingSpace
18
+ }
19
+
20
+ if (minIndent === Infinity || minIndent === 0) return text
21
+
22
+ return lines.map(line => {
23
+ if (line.trim().length === 0) return ''
24
+ return line.substring(minIndent)
25
+ }).join('\n')
26
+ }
27
+
28
+ const parse = (rawMd) => {
29
+ if (!rawMd) return []
30
+
31
+ const md = stripPrefixBeforeH1(rawMd)
32
+
33
+ let content = md.replace(/^#\s+.+$/m, '')
34
+ content = content.replace(/(?:^|\n)\s*(?:#{1,3}|\*\*)\s*(?:References|Citations|Sources)[\s\S]*$/i, '')
35
+ content = content.trim()
36
+
37
+ const sections = []
38
+
39
+ const combinedRegex = /(```[\s\S]*?```|((?:^|\n)\|[^\n]*\|(?:\n\|[^\n]*\|)*)|<summary>[\s\S]*?<\/summary>)/
40
+
41
+ let remaining = content
42
+
43
+ while (remaining) {
44
+ const match = remaining.match(combinedRegex)
45
+ if (!match) {
46
+ if (remaining.trim()) {
47
+ sections.push({ type: 'markdown', content: remaining.trim() })
48
+ }
49
+ break
50
+ }
51
+
52
+ const index = match.index
53
+ const matchedStr = match[0]
54
+ const preText = remaining.substring(0, index)
55
+
56
+ if (preText.trim()) {
57
+ sections.push({ type: 'markdown', content: preText.trim() })
58
+ }
59
+
60
+ const isCode = matchedStr.startsWith('```')
61
+ const isSummary = matchedStr.startsWith('<summary>')
62
+ const isTable = !isCode && !isSummary && matchedStr.trim().startsWith('|')
63
+
64
+ if (isCode || isTable || isSummary) {
65
+ let language = ''
66
+ let content = matchedStr.trim()
67
+
68
+ if (isCode) {
69
+ const match = matchedStr.match(/^```(\w+)/)
70
+ if (match && match[1]) language = match[1]
71
+ } else if (isSummary) {
72
+ content = content.replace(/^<summary>/, '').replace(/<\/summary>$/, '')
73
+ content = dedent(content)
74
+ }
75
+
76
+ sections.push({
77
+ type: 'card',
78
+ title: isCode ? 'Code' : (isSummary ? 'Summary' : 'Table'),
79
+ content: content,
80
+ contentType: isCode ? 'code' : (isSummary ? 'summary' : 'table'),
81
+ language: language
82
+ })
83
+ } else {
84
+ sections.push({ type: 'markdown', content: matchedStr })
85
+ }
86
+
87
+ remaining = remaining.substring(index + matchedStr.length)
88
+ }
89
+
90
+ return sections
91
+ }
92
+
93
+ const test1 = `
94
+ # Title
95
+
96
+ <summary>
97
+ Indented text.
98
+ It might become code block.
99
+ </summary>
100
+ `
101
+
102
+ console.log("\n--- Test 2 (After Fix) ---")
103
+ console.log(JSON.stringify(parse(test1), null, 2))
@@ -48,4 +48,5 @@ export interface RenderData {
48
48
  image_references: ImageReference[]
49
49
  stats: Stats
50
50
  total_time: number
51
+ theme_color?: string // Configurable theme color (hex)
51
52
  }
@@ -79,13 +79,37 @@ class HistoryManager:
79
79
  """Save conversation history to disk"""
80
80
  import os
81
81
  import time
82
+ import re
82
83
 
83
84
  if key not in self._history:
84
85
  return
85
86
 
86
87
  try:
87
88
  os.makedirs(save_dir, exist_ok=True)
88
- filename = f"{save_dir}/{key}_{int(time.time())}.md"
89
+
90
+ # Extract user's first message (question) for filename
91
+ user_question = ""
92
+ for msg in self._history[key]:
93
+ if msg.get("role") == "user":
94
+ content = msg.get("content", "")
95
+ # Handle content that might be a list (multimodal)
96
+ if isinstance(content, list):
97
+ for item in content:
98
+ if isinstance(item, dict) and item.get("type") == "text":
99
+ user_question = item.get("text", "")
100
+ break
101
+ else:
102
+ user_question = str(content)
103
+ break
104
+
105
+ # Clean and truncate question for filename (10 chars)
106
+ question_part = re.sub(r'[\\/:*?"<>|\n\r\t]', '', user_question)[:10].strip()
107
+ if not question_part:
108
+ question_part = "conversation"
109
+
110
+ # Format: YYYYMMDD_HHMMSS_question.md
111
+ time_str = time.strftime("%Y%m%d_%H%M%S", time.localtime())
112
+ filename = f"{save_dir}/{time_str}_{question_part}.md"
89
113
 
90
114
  # Formatter
91
115
  timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())