streaming, markdown, model selecting, and lots more
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue';
|
||||
|
||||
const props = defineProps<{ node: any }>();
|
||||
|
||||
const render = () => {
|
||||
const { node } = props;
|
||||
|
||||
if (node.type === 'text' || node.type === 'raw') return node.value;
|
||||
|
||||
if (node.type === 'element') {
|
||||
if (node.tagName === 'code') {
|
||||
const isBlock = node.position?.start.line !== node.position?.end.line;
|
||||
if (isBlock && node.children?.[0]?.type === 'text') {
|
||||
return h(resolveComponent('MarkdownShikiHighlight'), {
|
||||
code: node.children[0].value,
|
||||
lang: node.properties?.className?.[0]?.replace('language-', '') || 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return h(
|
||||
node.tagName,
|
||||
node.properties,
|
||||
node.children?.map((child: any, index: number) =>
|
||||
h(resolveComponent('MarkdownAstNode'), {
|
||||
node: child,
|
||||
key: `${node.tagName}-${index}`
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="render" />
|
||||
</template>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import type { RootContent } from 'hast';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
finished: boolean;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { $remark } = useNuxtApp();
|
||||
|
||||
function splitMarkdown(markdown: string): string[] {
|
||||
const paragraphs: string[] = [];
|
||||
let currentParagraph = "";
|
||||
let isInCodeBlock = false;
|
||||
|
||||
const lines = markdown.split("\n");
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.trim().startsWith("```")) {
|
||||
isInCodeBlock = !isInCodeBlock;
|
||||
}
|
||||
|
||||
if (line.trim() === "" && !isInCodeBlock) {
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
currentParagraph = "";
|
||||
}
|
||||
} else {
|
||||
currentParagraph += (currentParagraph === "" ? "" : "\n") + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
const partseAst = async (content: string) => {
|
||||
const mdast = $remark.parse(content);
|
||||
const hast = $remark.run(mdast);
|
||||
return hast;
|
||||
}
|
||||
|
||||
// SSR Initial Load
|
||||
const { data: hastParts } = await useAsyncData(`md-${props.id}`, async () => {
|
||||
return (await partseAst(props.content)).children;
|
||||
});
|
||||
|
||||
let activeIdx = 0;
|
||||
let partIdx = [0];
|
||||
|
||||
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
|
||||
const initialParts = splitMarkdown(props.content);
|
||||
|
||||
activeIdx = Math.max(0, initialParts.length - 1);
|
||||
|
||||
let currentOffset = 0;
|
||||
for (let i = 0; i < initialParts.length; i++) {
|
||||
partIdx[i] = currentOffset;
|
||||
|
||||
const tempAst = await partseAst(initialParts[i]!);
|
||||
currentOffset += tempAst.children.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const parts = computed(() => {
|
||||
return splitMarkdown(props.content);
|
||||
})
|
||||
|
||||
watch(parts, async (newParts) => {
|
||||
if (!hastParts.value) hastParts.value = [];
|
||||
|
||||
while (activeIdx < newParts.length - 1) {
|
||||
const finalHast = await partseAst(newParts[activeIdx]!);
|
||||
|
||||
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = base.concat(finalHast.children);
|
||||
|
||||
partIdx[activeIdx + 1] = hastParts.value!.length;
|
||||
activeIdx++;
|
||||
}
|
||||
|
||||
const currentString = newParts[activeIdx];
|
||||
if (currentString !== undefined) {
|
||||
const latestHast = await partseAst(currentString);
|
||||
|
||||
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = stableBase.concat(latestHast.children);
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-wrapper">
|
||||
<article class="markdown-body">
|
||||
<MarkdownAstNode v-for="(node, index) in hastParts" :key="`${id}-${index}`" :node="node" />
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
article>* {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:first-child {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:last-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
article>*:only-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid var(--color-highlight-high);
|
||||
}
|
||||
|
||||
li {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
ul>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul>li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background-color: var(--color-muted);
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
ul>li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 23px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--color-highlight);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
ul>li:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
counter-reset: ordered-list-counter var(--start-value, 0);
|
||||
}
|
||||
|
||||
ol[start] {
|
||||
--start-value: calc(attr(start type(<number>)) - 1);
|
||||
}
|
||||
|
||||
ol>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
counter-increment: ordered-list-counter;
|
||||
}
|
||||
|
||||
ol>li::before {
|
||||
content: counter(ordered-list-counter) ".";
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
html.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
ol:only-child,
|
||||
ul:only-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
code:not(pre code) {
|
||||
background-color: var(--color-highlight);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: var(--color-muted);
|
||||
border-left: 4px solid var(--color-highlight-high);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: calc(var(--spacing) * 4) 0;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
background-color: var(--color-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
table thead tr {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
table th {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
table tbody tr {
|
||||
background-color: var(--color-highlight);
|
||||
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
||||
}
|
||||
|
||||
table tbody tr:nth-of-type(even) {
|
||||
background-color: var(--color-highlight-low);
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
table tbody tr:hover {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
label>span {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: min-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { hashSync } from '~/utils/hash';
|
||||
const props = defineProps<{ code: string; lang: string }>();
|
||||
|
||||
const renderId = hashSync(props.code + props.lang);
|
||||
|
||||
const { data: html } = useAsyncData<string>(`shiki-${renderId}`, async () => parseCode());
|
||||
const lineNumberWidth = computed(() => {
|
||||
if (!html.value) return 1;
|
||||
// Count newlines in the generated HTML or the source code
|
||||
// Using props.code is safer and faster than parsing the HTML string
|
||||
return props.code.split('\n').length.toString().length;
|
||||
});
|
||||
|
||||
watch(() => props.code, async () => {
|
||||
html.value = await parseCode();
|
||||
});
|
||||
|
||||
async function parseCode() {
|
||||
const shiki = await getShikiHighlighter();
|
||||
let lang = props.lang.toLowerCase();
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
return shiki.codeToHtml(props.code.trim(), {
|
||||
lang,
|
||||
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl overflow-hidden code-container" :style="`--line-number-width: ${lineNumberWidth}ch`"
|
||||
:id="`code-${renderId}`" v-html="html">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.code-container {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-container>pre {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 1.625;
|
||||
counter-reset: lines;
|
||||
}
|
||||
|
||||
.code-container>pre>code .line::before {
|
||||
counter-increment: lines;
|
||||
content: counter(lines);
|
||||
width: var(--line-number-width);
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dark .code-container>pre>code .line::before {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.light .code-container>pre>code .line::before {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user