@demicodes/web-ui 0.3.1
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 +55 -0
- package/src/agent/AgentMessageInput.vue +164 -0
- package/src/agent/AgentMessageList.vue +124 -0
- package/src/agent/AgentPanel.vue +18 -0
- package/src/agent/AgentRoot.vue +17 -0
- package/src/agent/AgentTabBar.vue +422 -0
- package/src/agent/AgentTabItem.vue +150 -0
- package/src/agent/ContextUsageIndicator.vue +136 -0
- package/src/agent/ConversationListDropdown.vue +71 -0
- package/src/agent/ConversationStatusDot.vue +26 -0
- package/src/agent/ConversationView.vue +82 -0
- package/src/agent/MessageQueueBar.vue +67 -0
- package/src/agent/ModelSelector.vue +125 -0
- package/src/agent/ReasoningSelector.vue +106 -0
- package/src/agent/SelectorTrigger.vue +17 -0
- package/src/agent/__tests__/block-helpers.test.ts +73 -0
- package/src/agent/__tests__/input-actions.test.ts +130 -0
- package/src/agent/__tests__/input-editor.test.ts +15 -0
- package/src/agent/__tests__/pending-steers.test.ts +99 -0
- package/src/agent/__tests__/queue-submit.test.ts +14 -0
- package/src/agent/__tests__/reasoning.test.ts +32 -0
- package/src/agent/__tests__/tail-loading.test.ts +131 -0
- package/src/agent/__tests__/tool-rendering.test.ts +35 -0
- package/src/agent/__tests__/visible-blocks.test.ts +125 -0
- package/src/agent/block-helpers.ts +59 -0
- package/src/agent/block-types.ts +12 -0
- package/src/agent/blocks/AbortedBlock.vue +26 -0
- package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
- package/src/agent/blocks/AnsiText.vue +75 -0
- package/src/agent/blocks/AssistantTextBlock.vue +34 -0
- package/src/agent/blocks/CompactionBlock.vue +36 -0
- package/src/agent/blocks/ErrorBlock.vue +79 -0
- package/src/agent/blocks/FunctionalBlock.vue +118 -0
- package/src/agent/blocks/LoadingBlock.vue +28 -0
- package/src/agent/blocks/ThinkingBlock.vue +86 -0
- package/src/agent/blocks/ToolCallBlock.vue +40 -0
- package/src/agent/blocks/ToolGenericBlock.vue +43 -0
- package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
- package/src/agent/blocks/ToolShellBlock.vue +47 -0
- package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
- package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
- package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
- package/src/agent/blocks/ToolStatusBadge.vue +13 -0
- package/src/agent/blocks/ToolYieldBlock.vue +13 -0
- package/src/agent/blocks/UserBlock.vue +112 -0
- package/src/agent/conversation-runtime.ts +205 -0
- package/src/agent/conversation-status.ts +12 -0
- package/src/agent/message-input/input-model.ts +33 -0
- package/src/agent/message-input/useAgentInputActions.ts +96 -0
- package/src/agent/message-input/useAgentInputEditor.ts +90 -0
- package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
- package/src/agent/pending-steers.ts +134 -0
- package/src/agent/providers/ProviderIcon.vue +32 -0
- package/src/agent/providers/icons/claude.svg +1 -0
- package/src/agent/providers/icons/openai.svg +3 -0
- package/src/agent/queue-submit.ts +4 -0
- package/src/agent/reasoning.ts +45 -0
- package/src/agent/tail-loading.ts +31 -0
- package/src/agent/thinking-streaming.ts +7 -0
- package/src/agent/tool-rendering.ts +59 -0
- package/src/agent/types.ts +39 -0
- package/src/agent/ui-options.ts +21 -0
- package/src/agent/visible-blocks.ts +25 -0
- package/src/agent/workspace.ts +355 -0
- package/src/composables/useBlockVirtualizer.ts +290 -0
- package/src/composables/useContextMenuOwner.ts +44 -0
- package/src/composables/useOverlay.ts +18 -0
- package/src/composables/useTerminalTheme.ts +75 -0
- package/src/env.d.ts +10 -0
- package/src/infra/errors.ts +29 -0
- package/src/infra/i18n.ts +41 -0
- package/src/markdown/filePath.ts +54 -0
- package/src/markdown/highlight.ts +139 -0
- package/src/markdown/md.ts +58 -0
- package/src/markdown/render.ts +117 -0
- package/src/markdown/types.ts +20 -0
- package/src/overlay/appOverlay.ts +3 -0
- package/src/overlay/overlayStore.ts +54 -0
- package/src/store/createStore.ts +33 -0
- package/src/store/useStore.ts +7 -0
- package/src/styles/base.css +342 -0
- package/src/theme/appTheme.ts +56 -0
- package/src/theme/codeThemes.ts +454 -0
- package/src/theme/themeStore.ts +33 -0
- package/src/transport/agent-socket.ts +20 -0
- package/src/transport/control-client.ts +64 -0
- package/src/transport/protocol.ts +57 -0
- package/src/ui/Button.vue +30 -0
- package/src/ui/Checkbox.vue +25 -0
- package/src/ui/ContextMenu.vue +33 -0
- package/src/ui/Dialog.vue +48 -0
- package/src/ui/DropdownMenu.vue +77 -0
- package/src/ui/ErrorBox.vue +43 -0
- package/src/ui/HighlightText.vue +39 -0
- package/src/ui/IconButton.vue +30 -0
- package/src/ui/IndeterminateSpinner.vue +72 -0
- package/src/ui/InlineError.vue +25 -0
- package/src/ui/LoadMore.vue +32 -0
- package/src/ui/MarkdownPreview.vue +31 -0
- package/src/ui/Menu.vue +8 -0
- package/src/ui/MenuDivider.vue +6 -0
- package/src/ui/MenuItem.vue +48 -0
- package/src/ui/OptionMenu.vue +8 -0
- package/src/ui/OptionMenuGroup.vue +16 -0
- package/src/ui/OptionMenuItem.vue +28 -0
- package/src/ui/Popover.vue +90 -0
- package/src/ui/ScrollToBottomButton.vue +32 -0
- package/src/ui/SelectDropdown.vue +231 -0
- package/src/ui/Switch.vue +41 -0
- package/src/ui/TextInput.vue +44 -0
- package/src/ui/ThemeToggle.vue +49 -0
- package/src/ui/ToggleSwitch.vue +34 -0
- package/src/ui/Tooltip.vue +143 -0
- package/src/ui/blankClickGuard.ts +12 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import { useTheme } from '../theme/appTheme'
|
|
3
|
+
|
|
4
|
+
export interface TerminalTheme {
|
|
5
|
+
foreground: string
|
|
6
|
+
background: string
|
|
7
|
+
black: string
|
|
8
|
+
red: string
|
|
9
|
+
green: string
|
|
10
|
+
yellow: string
|
|
11
|
+
blue: string
|
|
12
|
+
magenta: string
|
|
13
|
+
cyan: string
|
|
14
|
+
white: string
|
|
15
|
+
brightBlack: string
|
|
16
|
+
brightRed: string
|
|
17
|
+
brightGreen: string
|
|
18
|
+
brightYellow: string
|
|
19
|
+
brightBlue: string
|
|
20
|
+
brightMagenta: string
|
|
21
|
+
brightCyan: string
|
|
22
|
+
brightWhite: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const darkTheme: TerminalTheme = {
|
|
26
|
+
background: '#171717',
|
|
27
|
+
foreground: '#d4d4d8',
|
|
28
|
+
black: '#171717',
|
|
29
|
+
red: '#f87171',
|
|
30
|
+
green: '#4ade80',
|
|
31
|
+
yellow: '#facc15',
|
|
32
|
+
blue: '#60a5fa',
|
|
33
|
+
magenta: '#c084fc',
|
|
34
|
+
cyan: '#22d3ee',
|
|
35
|
+
white: '#d4d4d8',
|
|
36
|
+
brightBlack: '#525252',
|
|
37
|
+
brightRed: '#fca5a5',
|
|
38
|
+
brightGreen: '#86efac',
|
|
39
|
+
brightYellow: '#fde68a',
|
|
40
|
+
brightBlue: '#93c5fd',
|
|
41
|
+
brightMagenta: '#d8b4fe',
|
|
42
|
+
brightCyan: '#67e8f9',
|
|
43
|
+
brightWhite: '#fafafa',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lightTheme: TerminalTheme = {
|
|
47
|
+
background: '#f9fafb',
|
|
48
|
+
foreground: '#1e1e1e',
|
|
49
|
+
black: '#1e1e1e',
|
|
50
|
+
red: '#dc2626',
|
|
51
|
+
green: '#16a34a',
|
|
52
|
+
yellow: '#a16207',
|
|
53
|
+
blue: '#2563eb',
|
|
54
|
+
magenta: '#9333ea',
|
|
55
|
+
cyan: '#0891b2',
|
|
56
|
+
white: '#d4d4d8',
|
|
57
|
+
brightBlack: '#737373',
|
|
58
|
+
brightRed: '#ef4444',
|
|
59
|
+
brightGreen: '#22c55e',
|
|
60
|
+
brightYellow: '#ca8a04',
|
|
61
|
+
brightBlue: '#3b82f6',
|
|
62
|
+
brightMagenta: '#a855f7',
|
|
63
|
+
brightCyan: '#06b6d4',
|
|
64
|
+
brightWhite: '#f5f5f5',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let terminalTheme: ComputedRef<TerminalTheme> | undefined
|
|
68
|
+
|
|
69
|
+
export function useTerminalTheme(): { terminalTheme: ComputedRef<TerminalTheme> } {
|
|
70
|
+
if (!terminalTheme) {
|
|
71
|
+
const { theme } = useTheme()
|
|
72
|
+
terminalTheme = computed<TerminalTheme>(() => (theme.value === 'dark' ? darkTheme : lightTheme))
|
|
73
|
+
}
|
|
74
|
+
return { terminalTheme }
|
|
75
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare module '*.vue' {
|
|
2
|
+
import type { DefineComponent } from 'vue'
|
|
3
|
+
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
|
4
|
+
export default component
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module '*.svg?raw' {
|
|
8
|
+
const content: string
|
|
9
|
+
export default content
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface Toast {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
message: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ReportErrorOptions {
|
|
10
|
+
userVisible?: boolean
|
|
11
|
+
expected?: boolean
|
|
12
|
+
detail?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const toasts = reactive<Toast[]>([])
|
|
16
|
+
|
|
17
|
+
export function reportError(title: string, error: unknown, options: ReportErrorOptions = {}): void {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
19
|
+
if (!options.expected) console.error(`[demi] ${title}: ${message}`, options.detail ?? '')
|
|
20
|
+
if (!options.userVisible) return
|
|
21
|
+
const id = globalThis.crypto.randomUUID()
|
|
22
|
+
toasts.push({ id, title, message })
|
|
23
|
+
setTimeout(() => dismissToast(id), 6000)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function dismissToast(id: string): void {
|
|
27
|
+
const index = toasts.findIndex((toast) => toast.id === id)
|
|
28
|
+
if (index >= 0) toasts.splice(index, 1)
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Minimal English string table. Keys mirror agent-gui's i18n keys for the ported
|
|
2
|
+
// tab/list/input surfaces; unmapped keys fall back to the key itself.
|
|
3
|
+
|
|
4
|
+
const messages: Record<string, string> = {
|
|
5
|
+
'common.rename': 'Rename',
|
|
6
|
+
'common.close': 'Close',
|
|
7
|
+
'agent.noWorkspace': 'No workspace open',
|
|
8
|
+
'agent.newConversation': 'New conversation',
|
|
9
|
+
'agent.tab.newTabRight': 'New tab to the right',
|
|
10
|
+
'agent.tab.closeOthers': 'Close others',
|
|
11
|
+
'agent.tab.closeToLeft': 'Close tabs to the left',
|
|
12
|
+
'agent.tab.closeToRight': 'Close tabs to the right',
|
|
13
|
+
'agent.tab.closeAll': 'Close all tabs',
|
|
14
|
+
'agent.tab.copyConversationId': 'Copy conversation ID',
|
|
15
|
+
'common.continue': 'Continue',
|
|
16
|
+
'agent.block.thinking': 'Thinking',
|
|
17
|
+
'agent.block.thinkingFor': 'Thinking for',
|
|
18
|
+
'agent.block.thoughtFor': 'Thought for',
|
|
19
|
+
'agent.block.error': 'Error',
|
|
20
|
+
'agent.block.stackTrace': 'Stack trace',
|
|
21
|
+
'agent.block.aborted': 'Aborted',
|
|
22
|
+
'agent.stats.contextWindow': 'Context window',
|
|
23
|
+
'agent.stats.cacheRead': 'Cache read',
|
|
24
|
+
'agent.stats.input': 'Input',
|
|
25
|
+
'agent.stats.output': 'Output',
|
|
26
|
+
'agent.stats.cacheWrite': 'Cache write',
|
|
27
|
+
'agent.stats.totalUsage': 'Total usage',
|
|
28
|
+
'agent.stats.unavailable': 'N/A',
|
|
29
|
+
'providers.model.thinking': 'Thinking',
|
|
30
|
+
'providers.reasoning': 'Reasoning effort',
|
|
31
|
+
'agent.context.compacting': 'Compacting context…',
|
|
32
|
+
'agent.context.noUsage': 'No usage recorded yet',
|
|
33
|
+
'agent.context.unavailable': 'Context usage unavailable',
|
|
34
|
+
'agent.context.compactHint': 'Click to compact context',
|
|
35
|
+
'agent.conversationList.placeholder': 'Search conversations…',
|
|
36
|
+
'agent.conversationList.empty': 'No conversations',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function t(key: string): string {
|
|
40
|
+
return messages[key] ?? key
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const LINE_RANGE_SUFFIX_RE = /:\d+(?:-\d+)?$/
|
|
2
|
+
|
|
3
|
+
function joinPath(cwd: string, path: string): string {
|
|
4
|
+
const trimmedCwd = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd
|
|
5
|
+
const trimmedPath = path.replace(/^\.\/+/, '')
|
|
6
|
+
return `${trimmedCwd}/${trimmedPath}`.replace(/\/{2,}/g, '/')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeFilePath(rawPath: string): string {
|
|
10
|
+
const trimmedPath = rawPath.trim()
|
|
11
|
+
if (!trimmedPath) return ''
|
|
12
|
+
|
|
13
|
+
const withoutScheme = trimmedPath.startsWith('file://')
|
|
14
|
+
? trimmedPath.slice('file://'.length)
|
|
15
|
+
: trimmedPath
|
|
16
|
+
const withoutLineRange = withoutScheme.replace(LINE_RANGE_SUFFIX_RE, '')
|
|
17
|
+
const withoutCurrentDirPrefix = withoutLineRange.replace(/^\.\/+/, '')
|
|
18
|
+
|
|
19
|
+
if (withoutCurrentDirPrefix.length > 1 && withoutCurrentDirPrefix.endsWith('/')) {
|
|
20
|
+
return withoutCurrentDirPrefix.slice(0, -1)
|
|
21
|
+
}
|
|
22
|
+
return withoutCurrentDirPrefix
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveAbsolutePath(cwd: string, rawPath: string): string {
|
|
26
|
+
const normalizedPath = normalizeFilePath(rawPath)
|
|
27
|
+
if (!normalizedPath) return ''
|
|
28
|
+
return normalizedPath.startsWith('/') ? normalizedPath : joinPath(cwd, normalizedPath)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isHttpUrl(path: string): boolean {
|
|
32
|
+
return path.startsWith('http://') || path.startsWith('https://')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isLikelyFilePath(rawPath: string): boolean {
|
|
36
|
+
const trimmedPath = rawPath.trim()
|
|
37
|
+
const normalizedPath = normalizeFilePath(trimmedPath)
|
|
38
|
+
if (!normalizedPath) return false
|
|
39
|
+
if (normalizedPath.startsWith('#')) return false
|
|
40
|
+
if (normalizedPath.startsWith('mailto:')) return false
|
|
41
|
+
if (isHttpUrl(normalizedPath)) return false
|
|
42
|
+
|
|
43
|
+
if (trimmedPath.endsWith('/')) return true
|
|
44
|
+
|
|
45
|
+
return normalizedPath.startsWith('/')
|
|
46
|
+
|| normalizedPath.startsWith('./')
|
|
47
|
+
|| normalizedPath.startsWith('../')
|
|
48
|
+
|| normalizedPath.includes('/')
|
|
49
|
+
|| /^[^/\\\s]+\.[^/\\\s]+$/.test(normalizedPath)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toLocalFileUrl(filePath: string): string {
|
|
53
|
+
return `local-file://${encodeURI(filePath)}`
|
|
54
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readonly, shallowRef } from 'vue'
|
|
2
|
+
import { createHighlighter, type BundledLanguage, type BundledTheme, type Highlighter } from 'shiki'
|
|
3
|
+
import { codeThemes, findCodeTheme } from '../theme/codeThemes'
|
|
4
|
+
import type { MarkdownThemeSnapshot } from './types'
|
|
5
|
+
|
|
6
|
+
const ALL_SHIKI_THEMES: BundledTheme[] = [
|
|
7
|
+
...new Set(codeThemes.flatMap((theme) => [theme.dark.shikiTheme, theme.light.shikiTheme])),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const LANGS = [
|
|
11
|
+
'typescript', 'javascript', 'tsx', 'jsx', 'json', 'jsonc',
|
|
12
|
+
'html', 'css', 'scss', 'less', 'vue',
|
|
13
|
+
'python', 'ruby', 'rust', 'go', 'java', 'kotlin', 'swift', 'dart', 'php',
|
|
14
|
+
'c', 'cpp', 'csharp',
|
|
15
|
+
'bash',
|
|
16
|
+
'yaml', 'toml', 'ini', 'xml', 'sql', 'graphql',
|
|
17
|
+
'markdown', 'dockerfile', 'makefile',
|
|
18
|
+
] as const
|
|
19
|
+
|
|
20
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
21
|
+
shell: 'bash',
|
|
22
|
+
sh: 'bash',
|
|
23
|
+
zsh: 'bash',
|
|
24
|
+
js: 'javascript',
|
|
25
|
+
ts: 'typescript',
|
|
26
|
+
py: 'python',
|
|
27
|
+
rb: 'ruby',
|
|
28
|
+
rs: 'rust',
|
|
29
|
+
cs: 'csharp',
|
|
30
|
+
yml: 'yaml',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const highlighter = shallowRef<Highlighter | null>(null)
|
|
34
|
+
const renderVersion = shallowRef(0)
|
|
35
|
+
|
|
36
|
+
const highlighterReady = createHighlighter({ themes: ALL_SHIKI_THEMES, langs: [...LANGS] }).then((instance) => {
|
|
37
|
+
highlighter.value = instance
|
|
38
|
+
renderVersion.value += 1
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function escapeHtml(str: string): string {
|
|
42
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function detectThemeMode(): 'light' | 'dark' {
|
|
46
|
+
if (typeof document === 'undefined') return 'dark'
|
|
47
|
+
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getShikiTheme(theme?: MarkdownThemeSnapshot): BundledTheme {
|
|
51
|
+
const snapshot = theme ?? { mode: detectThemeMode(), codeThemeId: codeThemes[0]?.id ?? 'one' }
|
|
52
|
+
const definition = findCodeTheme(snapshot.codeThemeId)
|
|
53
|
+
return snapshot.mode === 'light' ? definition.light.shikiTheme : definition.dark.shikiTheme
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveLang(lang: string): string {
|
|
57
|
+
if (!lang || lang === 'text' || lang === 'plaintext') return 'text'
|
|
58
|
+
const instance = highlighter.value
|
|
59
|
+
if (!instance) return 'text'
|
|
60
|
+
const resolved = LANG_ALIASES[lang] ?? lang
|
|
61
|
+
return instance.getLoadedLanguages().includes(resolved as BundledLanguage) ? resolved : 'text'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function codeToHtml(code: string, lang: string, theme?: MarkdownThemeSnapshot): string {
|
|
65
|
+
const instance = highlighter.value
|
|
66
|
+
if (!instance) return `<pre><code>${escapeHtml(code)}</code></pre>`
|
|
67
|
+
|
|
68
|
+
return instance.codeToHtml(code, {
|
|
69
|
+
lang: resolveLang(lang),
|
|
70
|
+
theme: getShikiTheme(theme),
|
|
71
|
+
transformers: [{
|
|
72
|
+
pre(node) {
|
|
73
|
+
const style = String(node.properties?.['style'] ?? '')
|
|
74
|
+
node.properties['style'] = style.replace(/background-color:[^;]+;?\s*/g, '').trim() || undefined
|
|
75
|
+
},
|
|
76
|
+
}],
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TokenSpan {
|
|
81
|
+
content: string
|
|
82
|
+
color?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function codeToTokenLines(code: string, lang: string, theme?: MarkdownThemeSnapshot): TokenSpan[][] {
|
|
86
|
+
const instance = highlighter.value
|
|
87
|
+
if (!instance) {
|
|
88
|
+
return splitLines(code).map((line) => [{ content: line }])
|
|
89
|
+
}
|
|
90
|
+
const resolved = resolveLang(lang) as BundledLanguage
|
|
91
|
+
const { tokens } = instance.codeToTokens(code, { lang: resolved, theme: getShikiTheme(theme) })
|
|
92
|
+
return tokens.map((line) => line.map((t): TokenSpan => t.color ? { content: t.content, color: t.color } : { content: t.content }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const EXT_TO_LANG: Record<string, string> = {
|
|
96
|
+
'.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx',
|
|
97
|
+
'.cjs': 'javascript', '.mjs': 'javascript',
|
|
98
|
+
'.json': 'json', '.jsonc': 'jsonc',
|
|
99
|
+
'.vue': 'vue', '.html': 'html', '.htm': 'html',
|
|
100
|
+
'.css': 'css', '.scss': 'scss', '.less': 'less',
|
|
101
|
+
'.md': 'markdown',
|
|
102
|
+
'.py': 'python', '.rb': 'ruby', '.rs': 'rust', '.go': 'go',
|
|
103
|
+
'.java': 'java', '.kt': 'kotlin', '.swift': 'swift', '.dart': 'dart', '.php': 'php',
|
|
104
|
+
'.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp', '.cs': 'csharp',
|
|
105
|
+
'.sh': 'bash', '.zsh': 'bash', '.bash': 'bash',
|
|
106
|
+
'.yml': 'yaml', '.yaml': 'yaml',
|
|
107
|
+
'.xml': 'xml', '.svg': 'xml',
|
|
108
|
+
'.sql': 'sql', '.graphql': 'graphql',
|
|
109
|
+
'.toml': 'toml', '.ini': 'ini', '.env': 'ini',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const FILENAME_TO_LANG: Record<string, string> = {
|
|
113
|
+
Dockerfile: 'dockerfile',
|
|
114
|
+
Makefile: 'makefile',
|
|
115
|
+
'.gitignore': 'ini',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getLanguageFromPath(filepath: string): string {
|
|
119
|
+
const filename = filepath.split('/').pop() ?? ''
|
|
120
|
+
const byName = FILENAME_TO_LANG[filename]
|
|
121
|
+
if (byName) return byName
|
|
122
|
+
const dotIndex = filename.lastIndexOf('.')
|
|
123
|
+
if (dotIndex <= 0) return 'text'
|
|
124
|
+
return EXT_TO_LANG[filename.slice(dotIndex).toLowerCase()] ?? 'text'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useMarkdownRenderVersion() {
|
|
128
|
+
return readonly(renderVersion)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function waitForMarkdownHighlighter() {
|
|
132
|
+
await highlighterReady
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function splitLines(code: string): string[] {
|
|
136
|
+
const lines = code.split('\n')
|
|
137
|
+
if (lines.at(-1) === '') lines.pop()
|
|
138
|
+
return lines
|
|
139
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
import DOMPurify from 'dompurify'
|
|
3
|
+
import { renderMarkdown } from './render'
|
|
4
|
+
import { isHttpUrl } from './filePath'
|
|
5
|
+
import { codeToHtml } from './highlight'
|
|
6
|
+
import type { MarkdownRenderOptions } from './types'
|
|
7
|
+
|
|
8
|
+
function escapeHtml(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const userMarked = new Marked({
|
|
17
|
+
gfm: true,
|
|
18
|
+
breaks: true,
|
|
19
|
+
renderer: {
|
|
20
|
+
html({ text }) {
|
|
21
|
+
return escapeHtml(text)
|
|
22
|
+
},
|
|
23
|
+
code({ text, lang }) {
|
|
24
|
+
return codeToHtml(text, lang ?? '')
|
|
25
|
+
},
|
|
26
|
+
codespan({ text }) {
|
|
27
|
+
return `<code>${escapeHtml(text)}</code>`
|
|
28
|
+
},
|
|
29
|
+
link(token) {
|
|
30
|
+
const href = token.href
|
|
31
|
+
const body = this.parser.parseInline(token.tokens)
|
|
32
|
+
if (isHttpUrl(href)) {
|
|
33
|
+
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${body}</a>`
|
|
34
|
+
}
|
|
35
|
+
return body
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
// Deliberately no KaTeX on user content: people type `$` for shell vars ($PATH), prices,
|
|
40
|
+
// and when discussing LaTeX itself, so rendering math here causes far more false positives
|
|
41
|
+
// than it's worth. Math rendering applies to assistant output only (see render.ts).
|
|
42
|
+
|
|
43
|
+
function escapeBlockSyntax(src: string): string {
|
|
44
|
+
return src
|
|
45
|
+
.replace(/^(\d+)([.)]) /gm, '$1\\$2 ')
|
|
46
|
+
.replace(/^([-*+]) /gm, '\\$1 ')
|
|
47
|
+
.replace(/^(#{1,6}) /gm, '\\$1 ')
|
|
48
|
+
.replace(/^(>)/gm, '\\$1')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const md = {
|
|
52
|
+
render(src: string, options?: MarkdownRenderOptions): string {
|
|
53
|
+
return DOMPurify.sanitize(renderMarkdown(src, options), { ADD_ATTR: ['style', 'data-file-link'] })
|
|
54
|
+
},
|
|
55
|
+
renderUser(src: string): string {
|
|
56
|
+
return DOMPurify.sanitize(userMarked.parse(escapeBlockSyntax(src), { async: false }) as string)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
import markedKatex from 'marked-katex-extension'
|
|
3
|
+
import type { MarkdownRenderOptions } from './types'
|
|
4
|
+
import { codeToHtml } from './highlight'
|
|
5
|
+
import { isHttpUrl, isLikelyFilePath, normalizeFilePath, resolveAbsolutePath, toLocalFileUrl } from './filePath'
|
|
6
|
+
|
|
7
|
+
// `$...$` inline / `$$...$$` block LaTeX, rendered to self-contained HTML (KaTeX CSS is loaded
|
|
8
|
+
// by the app). `nonStandard` lets inline math sit flush against CJK text the model writes;
|
|
9
|
+
// `throwOnError` keeps malformed math from blowing up the whole message.
|
|
10
|
+
const katexExtension = markedKatex({ throwOnError: false, nonStandard: true, output: 'html' })
|
|
11
|
+
|
|
12
|
+
const INLINE_CODE_RE = /`([^`\n]+)`/g
|
|
13
|
+
const MARKDOWN_LINK_RE = /\[[^\]]*]\(([^)]+)\)/g
|
|
14
|
+
|
|
15
|
+
function escapeHtml(text: string): string {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractLinkHref(rawTarget: string): string {
|
|
24
|
+
const trimmed = rawTarget.trim()
|
|
25
|
+
if (!trimmed) return ''
|
|
26
|
+
|
|
27
|
+
const unwrapped = trimmed.startsWith('<') && trimmed.endsWith('>')
|
|
28
|
+
? trimmed.slice(1, -1)
|
|
29
|
+
: trimmed
|
|
30
|
+
const separatorIndex = unwrapped.search(/\s/)
|
|
31
|
+
return separatorIndex === -1 ? unwrapped : unwrapped.slice(0, separatorIndex)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractFilePathCandidates(src: string): Set<string> {
|
|
35
|
+
const candidates = new Set<string>()
|
|
36
|
+
|
|
37
|
+
for (const match of src.matchAll(INLINE_CODE_RE)) {
|
|
38
|
+
const codeText = match[1]
|
|
39
|
+
if (!codeText || !isLikelyFilePath(codeText)) continue
|
|
40
|
+
const normalizedPath = normalizeFilePath(codeText)
|
|
41
|
+
if (normalizedPath) candidates.add(normalizedPath)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const match of src.matchAll(MARKDOWN_LINK_RE)) {
|
|
45
|
+
const rawHref = match[1]
|
|
46
|
+
if (!rawHref) continue
|
|
47
|
+
const href = extractLinkHref(rawHref)
|
|
48
|
+
if (!isLikelyFilePath(href)) continue
|
|
49
|
+
const normalizedPath = normalizeFilePath(href)
|
|
50
|
+
if (normalizedPath) candidates.add(normalizedPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return candidates
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveImageSource(href: string, basePath?: string): string {
|
|
57
|
+
const trimmedHref = href.trim()
|
|
58
|
+
if (!trimmedHref) return ''
|
|
59
|
+
if (isHttpUrl(trimmedHref) || trimmedHref.startsWith('data:') || trimmedHref.startsWith('local-file:')) return trimmedHref
|
|
60
|
+
if (!basePath) return trimmedHref
|
|
61
|
+
const absPath = resolveAbsolutePath(basePath, trimmedHref)
|
|
62
|
+
return absPath ? toLocalFileUrl(absPath) : trimmedHref
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createMarked(options?: MarkdownRenderOptions) {
|
|
66
|
+
const knownPaths = options?.knownPaths
|
|
67
|
+
const basePath = options?.basePath
|
|
68
|
+
|
|
69
|
+
const marked = new Marked({
|
|
70
|
+
gfm: true,
|
|
71
|
+
breaks: true,
|
|
72
|
+
renderer: {
|
|
73
|
+
html({ text }) {
|
|
74
|
+
return escapeHtml(text)
|
|
75
|
+
},
|
|
76
|
+
code({ text, lang }) {
|
|
77
|
+
return codeToHtml(text, lang ?? '', options?.theme)
|
|
78
|
+
},
|
|
79
|
+
codespan({ text }) {
|
|
80
|
+
const normalizedPath = normalizeFilePath(text)
|
|
81
|
+
if (isLikelyFilePath(text) && knownPaths?.has(normalizedPath)) {
|
|
82
|
+
return `<a class="file-link" href="${escapeHtml(normalizedPath)}" data-file-link>${escapeHtml(text)}</a>`
|
|
83
|
+
}
|
|
84
|
+
return `<code>${escapeHtml(text)}</code>`
|
|
85
|
+
},
|
|
86
|
+
link(token) {
|
|
87
|
+
const href = extractLinkHref(token.href)
|
|
88
|
+
const body = this.parser.parseInline(token.tokens)
|
|
89
|
+
if (isHttpUrl(href)) {
|
|
90
|
+
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${body}</a>`
|
|
91
|
+
}
|
|
92
|
+
if (isLikelyFilePath(href)) {
|
|
93
|
+
const normalizedPath = normalizeFilePath(href)
|
|
94
|
+
if (!knownPaths || knownPaths.has(normalizedPath)) {
|
|
95
|
+
return `<a href="${escapeHtml(normalizedPath)}" data-file-link>${body}</a>`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return body
|
|
99
|
+
},
|
|
100
|
+
image(token) {
|
|
101
|
+
const href = extractLinkHref(token.href)
|
|
102
|
+
const safeSrc = resolveImageSource(href, basePath)
|
|
103
|
+
if (!safeSrc) return escapeHtml(token.text)
|
|
104
|
+
|
|
105
|
+
const title = token.title ? ` title="${escapeHtml(token.title)}"` : ''
|
|
106
|
+
const alt = escapeHtml(token.text)
|
|
107
|
+
return `<img src="${escapeHtml(safeSrc)}" alt="${alt}"${title} />`
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
marked.use(katexExtension)
|
|
112
|
+
return marked
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderMarkdown(src: string, options?: MarkdownRenderOptions): string {
|
|
116
|
+
return createMarked(options).parse(src, { async: false }) as string
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface MarkdownThemeSnapshot {
|
|
2
|
+
mode: 'light' | 'dark'
|
|
3
|
+
codeThemeId: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MarkdownRenderOptions {
|
|
7
|
+
knownPaths?: Set<string>
|
|
8
|
+
basePath?: string
|
|
9
|
+
theme?: MarkdownThemeSnapshot
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MarkdownRenderer = (src: string, options?: MarkdownRenderOptions) => string
|
|
13
|
+
|
|
14
|
+
export interface MarkdownLinkClick {
|
|
15
|
+
href: string
|
|
16
|
+
event: MouseEvent
|
|
17
|
+
basePath?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type MarkdownLinkHandler = (payload: MarkdownLinkClick) => void
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createStore } from '../store/createStore'
|
|
2
|
+
|
|
3
|
+
export interface OverlayEntry {
|
|
4
|
+
id: string
|
|
5
|
+
close: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OverlayStore {
|
|
9
|
+
state: {
|
|
10
|
+
entries: OverlayEntry[]
|
|
11
|
+
}
|
|
12
|
+
hasEntries(): boolean
|
|
13
|
+
closeTop(): void
|
|
14
|
+
push(id: string, close: () => void): () => void
|
|
15
|
+
remove(id: string): void
|
|
16
|
+
subscribe(listener: () => void): () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createOverlayStore(): OverlayStore {
|
|
20
|
+
const store = createStore<{ entries: OverlayEntry[] }>({ entries: [] })
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
state: store.state,
|
|
24
|
+
subscribe: store.subscribe,
|
|
25
|
+
hasEntries() {
|
|
26
|
+
return store.state.entries.length > 0
|
|
27
|
+
},
|
|
28
|
+
closeTop() {
|
|
29
|
+
const top = store.state.entries[store.state.entries.length - 1]
|
|
30
|
+
if (!top) return
|
|
31
|
+
top.close()
|
|
32
|
+
},
|
|
33
|
+
push(id, close) {
|
|
34
|
+
store.update((state) => {
|
|
35
|
+
state.entries.push({ id, close })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
const index = store.state.entries.findIndex((entry) => entry.id === id)
|
|
40
|
+
if (index < 0) return
|
|
41
|
+
store.update((state) => {
|
|
42
|
+
state.entries.splice(index, 1)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
remove(id) {
|
|
47
|
+
const index = store.state.entries.findIndex((entry) => entry.id === id)
|
|
48
|
+
if (index < 0) return
|
|
49
|
+
store.update((state) => {
|
|
50
|
+
state.entries.splice(index, 1)
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface Store<T extends object> {
|
|
4
|
+
state: T
|
|
5
|
+
patch(partial: Partial<T>): void
|
|
6
|
+
update(mutator: (state: T) => void): void
|
|
7
|
+
subscribe(listener: () => void): () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createStore<T extends object>(initialState: T): Store<T> {
|
|
11
|
+
const state = reactive(initialState) as T
|
|
12
|
+
const listeners = new Set<() => void>()
|
|
13
|
+
|
|
14
|
+
function notify() {
|
|
15
|
+
for (const listener of listeners) listener()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
state,
|
|
20
|
+
patch(partial) {
|
|
21
|
+
Object.assign(state, partial)
|
|
22
|
+
notify()
|
|
23
|
+
},
|
|
24
|
+
update(mutator) {
|
|
25
|
+
mutator(state)
|
|
26
|
+
notify()
|
|
27
|
+
},
|
|
28
|
+
subscribe(listener) {
|
|
29
|
+
listeners.add(listener)
|
|
30
|
+
return () => listeners.delete(listener)
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|