@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,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAttrs } from 'vue'
|
|
3
|
+
import type { MessageListBlock } from '../pending-steers'
|
|
4
|
+
import UserBlock from './UserBlock.vue'
|
|
5
|
+
import ThinkingBlock from './ThinkingBlock.vue'
|
|
6
|
+
import AssistantTextBlock from './AssistantTextBlock.vue'
|
|
7
|
+
import ToolCallBlock from './ToolCallBlock.vue'
|
|
8
|
+
import ErrorBlock from './ErrorBlock.vue'
|
|
9
|
+
import AbortedBlock from './AbortedBlock.vue'
|
|
10
|
+
import CompactionBlock from './CompactionBlock.vue'
|
|
11
|
+
|
|
12
|
+
defineOptions({ inheritAttrs: false })
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
block: MessageListBlock
|
|
16
|
+
conversationId: string
|
|
17
|
+
isThinkingStreaming: boolean
|
|
18
|
+
thinkingEndedAt?: string | null
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
continue: []
|
|
23
|
+
retry: []
|
|
24
|
+
deletePendingSteer: [id: string]
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const attrs = useAttrs()
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UserBlock
|
|
32
|
+
v-if="block.type === 'user'"
|
|
33
|
+
v-bind="attrs"
|
|
34
|
+
:content="block.content"
|
|
35
|
+
/>
|
|
36
|
+
<UserBlock
|
|
37
|
+
v-else-if="block.type === 'steer'"
|
|
38
|
+
v-bind="attrs"
|
|
39
|
+
:content="block.content"
|
|
40
|
+
variant="steer"
|
|
41
|
+
/>
|
|
42
|
+
<UserBlock
|
|
43
|
+
v-else-if="block.type === 'pending_steer'"
|
|
44
|
+
v-bind="attrs"
|
|
45
|
+
:content="block.content"
|
|
46
|
+
variant="steer"
|
|
47
|
+
pending
|
|
48
|
+
deletable
|
|
49
|
+
@delete="emit('deletePendingSteer', block.pendingSteerId)"
|
|
50
|
+
/>
|
|
51
|
+
<div v-else v-bind="attrs">
|
|
52
|
+
<ThinkingBlock
|
|
53
|
+
v-if="block.type === 'thinking'"
|
|
54
|
+
:thinking="block.text"
|
|
55
|
+
:is-streaming="isThinkingStreaming"
|
|
56
|
+
:created-at="block.createdAt"
|
|
57
|
+
:ended-at="thinkingEndedAt"
|
|
58
|
+
/>
|
|
59
|
+
<AssistantTextBlock
|
|
60
|
+
v-else-if="block.type === 'text'"
|
|
61
|
+
:content="block.text"
|
|
62
|
+
/>
|
|
63
|
+
<div v-else-if="block.type === 'tool_call'" class="overflow-hidden px-[var(--agent-pad-x,2rem)]">
|
|
64
|
+
<ToolCallBlock :block="block" :conversation-id="props.conversationId" :is-streaming="block.status === 'executing'" />
|
|
65
|
+
</div>
|
|
66
|
+
<div v-else-if="block.type === 'error'" class="px-[var(--agent-pad-x,2rem)]">
|
|
67
|
+
<ErrorBlock
|
|
68
|
+
:message="block.message"
|
|
69
|
+
:code="block.code"
|
|
70
|
+
@continue="emit('continue')"
|
|
71
|
+
@retry="emit('retry')"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
<div v-else-if="block.type === 'abort'" class="px-[var(--agent-pad-x,2rem)]">
|
|
75
|
+
<AbortedBlock
|
|
76
|
+
@continue="emit('continue')"
|
|
77
|
+
@retry="emit('retry')"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<CompactionBlock
|
|
81
|
+
v-else-if="block.type === 'compaction_boundary'"
|
|
82
|
+
:summary="block.summary"
|
|
83
|
+
:summary-tokens="block.summaryTokens"
|
|
84
|
+
:is-compacting="false"
|
|
85
|
+
:created-at="block.createdAt"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, toRef } from 'vue'
|
|
3
|
+
import { refThrottled } from '@vueuse/core'
|
|
4
|
+
import AnsiToHtml from 'ansi-to-html'
|
|
5
|
+
import { useTerminalTheme } from '@demicodes/web-ui/composables/useTerminalTheme'
|
|
6
|
+
|
|
7
|
+
const RENDER_THROTTLE = 80
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
content: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const throttledContent = refThrottled(toRef(props, 'content'), RENDER_THROTTLE)
|
|
14
|
+
|
|
15
|
+
const { terminalTheme } = useTerminalTheme()
|
|
16
|
+
|
|
17
|
+
const ansiConverter = computed(() => {
|
|
18
|
+
const t = terminalTheme.value
|
|
19
|
+
return new AnsiToHtml({
|
|
20
|
+
fg: t.foreground,
|
|
21
|
+
bg: t.background,
|
|
22
|
+
escapeXML: true,
|
|
23
|
+
colors: {
|
|
24
|
+
0: t.black,
|
|
25
|
+
1: t.red,
|
|
26
|
+
2: t.green,
|
|
27
|
+
3: t.yellow,
|
|
28
|
+
4: t.blue,
|
|
29
|
+
5: t.magenta,
|
|
30
|
+
6: t.cyan,
|
|
31
|
+
7: t.white,
|
|
32
|
+
8: t.brightBlack,
|
|
33
|
+
9: t.brightRed,
|
|
34
|
+
10: t.brightGreen,
|
|
35
|
+
11: t.brightYellow,
|
|
36
|
+
12: t.brightBlue,
|
|
37
|
+
13: t.brightMagenta,
|
|
38
|
+
14: t.brightCyan,
|
|
39
|
+
15: t.brightWhite,
|
|
40
|
+
} as Record<number, string>,
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function normalizeTerminalContent(content: string): string {
|
|
45
|
+
if (!content.includes('\r')) return content
|
|
46
|
+
|
|
47
|
+
const lines: string[] = []
|
|
48
|
+
let currentLine = ''
|
|
49
|
+
|
|
50
|
+
for (const char of content) {
|
|
51
|
+
if (char === '\r') {
|
|
52
|
+
currentLine = ''
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (char === '\n') {
|
|
56
|
+
lines.push(currentLine)
|
|
57
|
+
currentLine = ''
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
currentLine += char
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push(currentLine)
|
|
64
|
+
return lines.join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const renderedHtml = computed(() => {
|
|
68
|
+
if (!throttledContent.value) return ''
|
|
69
|
+
return ansiConverter.value.toHtml(normalizeTerminalContent(throttledContent.value))
|
|
70
|
+
})
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<pre class="font-mono text-[13px] leading-[1.4] text-fg-body whitespace-pre-wrap break-words" v-html="renderedHtml" />
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { md } from '@demicodes/web-ui/markdown/md'
|
|
4
|
+
import { isHttpUrl } from '@demicodes/web-ui/markdown/filePath'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
content: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const renderedMarkdown = computed(() => md.render(props.content))
|
|
11
|
+
|
|
12
|
+
function handleClick(event: MouseEvent) {
|
|
13
|
+
const target = (event.target as HTMLElement).closest('a')
|
|
14
|
+
if (!target) return
|
|
15
|
+
|
|
16
|
+
const href = target.getAttribute('href')
|
|
17
|
+
if (!href) return
|
|
18
|
+
|
|
19
|
+
if (isHttpUrl(href)) {
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
window.open(href, '_blank', 'noopener,noreferrer')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="px-[var(--agent-pad-x,2rem)] py-1.5">
|
|
28
|
+
<div
|
|
29
|
+
class="markdown-body select-text text-sm leading-relaxed text-fg-body"
|
|
30
|
+
v-html="renderedMarkdown"
|
|
31
|
+
@click="handleClick"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
summary: string
|
|
6
|
+
summaryTokens: number
|
|
7
|
+
isCompacting: boolean
|
|
8
|
+
createdAt: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
function formatTokens(tokens: number): string {
|
|
12
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
|
13
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
|
14
|
+
return String(tokens)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const label = computed(() => {
|
|
18
|
+
if (props.isCompacting) return 'Compacting context...'
|
|
19
|
+
return `Context compacted to ~${formatTokens(props.summaryTokens)} tokens`
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="flex items-center gap-3 px-[var(--agent-pad-x,2rem)]">
|
|
25
|
+
<div class="h-px flex-1 bg-overlay/6" />
|
|
26
|
+
<span
|
|
27
|
+
v-if="isCompacting"
|
|
28
|
+
class="thinking-shimmer text-[11px] font-medium tracking-wide text-fg-subtle"
|
|
29
|
+
>{{ label }}</span>
|
|
30
|
+
<span
|
|
31
|
+
v-else
|
|
32
|
+
class="text-[11px] font-medium tracking-wide text-fg-subtle"
|
|
33
|
+
>{{ label }}</span>
|
|
34
|
+
<div class="h-px flex-1 bg-overlay/6" />
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useClipboard } from '@vueuse/core'
|
|
4
|
+
import { RightSmallLine } from '@mingcute/vue/right-small'
|
|
5
|
+
import { CopyLine } from '@mingcute/vue/copy'
|
|
6
|
+
import { CheckLine } from '@mingcute/vue/check'
|
|
7
|
+
import { ArrowRightLine } from '@mingcute/vue/arrow-right'
|
|
8
|
+
import { t } from '@demicodes/web-ui/infra/i18n'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
message: string
|
|
12
|
+
code?: string | null
|
|
13
|
+
stack?: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
continue: []
|
|
18
|
+
retry: []
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
function handleRecovery() {
|
|
22
|
+
emit('continue')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isStackOpen = ref(false)
|
|
26
|
+
|
|
27
|
+
const copyableText = computed(() => {
|
|
28
|
+
const parts = [props.message]
|
|
29
|
+
if (props.code) parts.unshift(`[${props.code}]`)
|
|
30
|
+
if (props.stack) parts.push(props.stack)
|
|
31
|
+
return parts.join('\n')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div class="overflow-hidden rounded-lg border border-on-danger-muted bg-tint-danger">
|
|
39
|
+
<div class="flex items-center gap-2 px-3 py-2">
|
|
40
|
+
<span class="text-[12px] font-medium uppercase tracking-wide text-on-danger">{{ t('agent.block.error') }}</span>
|
|
41
|
+
<span
|
|
42
|
+
v-if="props.code"
|
|
43
|
+
class="rounded bg-tint-danger-strong px-1.5 py-0.5 font-mono text-[11px] text-on-danger-muted"
|
|
44
|
+
>{{ props.code }}</span>
|
|
45
|
+
<div class="flex-1" />
|
|
46
|
+
<div
|
|
47
|
+
class="flex cursor-pointer items-center rounded-md p-1 text-on-danger-muted transition-colors hover:bg-tint-danger hover:text-on-danger-muted"
|
|
48
|
+
@click="copy(copyableText)"
|
|
49
|
+
>
|
|
50
|
+
<CheckLine v-if="copied" :size="13" class="text-on-success" />
|
|
51
|
+
<CopyLine v-else :size="13" />
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="border-t border-on-danger-muted px-3 py-2">
|
|
55
|
+
<pre class="whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-on-danger">{{ props.message }}</pre>
|
|
56
|
+
</div>
|
|
57
|
+
<div v-if="props.stack">
|
|
58
|
+
<div
|
|
59
|
+
class="flex cursor-pointer items-center gap-1 border-t border-on-danger-muted px-3 py-1.5 text-[11px] text-on-danger-muted transition-colors hover:bg-tint-danger"
|
|
60
|
+
@click="isStackOpen = !isStackOpen"
|
|
61
|
+
>
|
|
62
|
+
<RightSmallLine :size="14" class="transition-transform duration-150" :class="isStackOpen ? 'rotate-90' : ''" />
|
|
63
|
+
<span class="uppercase tracking-wide">{{ t('agent.block.stackTrace') }}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div v-if="isStackOpen" class="border-t border-on-danger-muted px-3 py-2">
|
|
66
|
+
<pre class="whitespace-pre-wrap break-words font-mono text-[11px] leading-4.5 text-on-danger-muted">{{ props.stack }}</pre>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="flex items-center justify-end border-t border-on-danger-muted px-3 py-2">
|
|
70
|
+
<div
|
|
71
|
+
class="flex cursor-pointer items-center gap-1.5 rounded-md bg-tint-danger-strong px-2.5 py-1 text-[12px] font-medium text-on-danger transition-colors hover:bg-tint-danger-strong"
|
|
72
|
+
@click="handleRecovery"
|
|
73
|
+
>
|
|
74
|
+
<ArrowRightLine :size="13" />
|
|
75
|
+
{{ t('common.continue') }}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onUpdated, ref, useSlots, watch } from 'vue'
|
|
3
|
+
import { RightLine } from '@mingcute/vue/right'
|
|
4
|
+
import ToolStatusBadge from './ToolStatusBadge.vue'
|
|
5
|
+
|
|
6
|
+
const ACTIVE_OUTPUT_CLOSE_DELAY_MS = 1000
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
label?: string
|
|
10
|
+
detail?: string
|
|
11
|
+
suffix?: string
|
|
12
|
+
trailing?: string
|
|
13
|
+
loading?: boolean
|
|
14
|
+
error?: boolean
|
|
15
|
+
errorText?: string
|
|
16
|
+
/** Keep the body scrolled to the latest line while content streams in (e.g. live thinking). */
|
|
17
|
+
stickBottom?: boolean
|
|
18
|
+
/** Keep the block open while active output is being produced. */
|
|
19
|
+
openWhile?: boolean
|
|
20
|
+
/** Force expandability instead of inferring it from the body slot (slot presence isn't reactive). */
|
|
21
|
+
expandable?: boolean
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const slots = useSlots()
|
|
25
|
+
const isOpen = defineModel<boolean>('open', { default: false })
|
|
26
|
+
const hasBodySlot = () => !!slots['body']
|
|
27
|
+
const isExpandable = computed(() => props.expandable || hasBodySlot() || !!props.errorText)
|
|
28
|
+
const bodyScroll = ref<HTMLElement>()
|
|
29
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
30
|
+
|
|
31
|
+
function clearCloseTimer() {
|
|
32
|
+
if (!closeTimer) return
|
|
33
|
+
clearTimeout(closeTimer)
|
|
34
|
+
closeTimer = undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function closeAfterActiveOutputSettles() {
|
|
38
|
+
if (!isOpen.value) return
|
|
39
|
+
clearCloseTimer()
|
|
40
|
+
closeTimer = setTimeout(() => {
|
|
41
|
+
closeTimer = undefined
|
|
42
|
+
if (!props.openWhile && !props.errorText) isOpen.value = false
|
|
43
|
+
}, ACTIVE_OUTPUT_CLOSE_DELAY_MS)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
watch(
|
|
47
|
+
[() => props.errorText, () => props.openWhile, isExpandable],
|
|
48
|
+
([errorText, openWhile, expandable]) => {
|
|
49
|
+
clearCloseTimer()
|
|
50
|
+
if (errorText) {
|
|
51
|
+
isOpen.value = true
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (openWhile === undefined) return
|
|
55
|
+
if (openWhile && expandable) {
|
|
56
|
+
isOpen.value = true
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
closeAfterActiveOutputSettles()
|
|
60
|
+
},
|
|
61
|
+
{ immediate: true },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
onBeforeUnmount(clearCloseTimer)
|
|
65
|
+
|
|
66
|
+
onUpdated(() => {
|
|
67
|
+
if (props.stickBottom && isOpen.value && bodyScroll.value) {
|
|
68
|
+
bodyScroll.value.scrollTop = bodyScroll.value.scrollHeight
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div class="overflow-hidden">
|
|
75
|
+
<div
|
|
76
|
+
class="flex select-none items-center gap-2 py-1 text-[13px] text-fg-muted"
|
|
77
|
+
@click="isExpandable && (isOpen = !isOpen)"
|
|
78
|
+
>
|
|
79
|
+
<div class="flex size-4 shrink-0 items-center justify-center">
|
|
80
|
+
<slot name="icon" />
|
|
81
|
+
</div>
|
|
82
|
+
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
|
83
|
+
<span v-if="label" class="shrink-0" :class="loading ? 'thinking-shimmer' : ''">{{ label }}</span>
|
|
84
|
+
<slot v-if="slots['default']" :loading="loading" />
|
|
85
|
+
<span v-else-if="detail" class="min-w-0 truncate font-mono text-fg-body" :class="loading ? 'thinking-shimmer' : ''">{{ detail }}</span>
|
|
86
|
+
<span v-if="suffix" class="shrink-0 text-fg-subtle" :class="loading ? 'thinking-shimmer' : ''">{{ suffix }}</span>
|
|
87
|
+
</div>
|
|
88
|
+
<span class="-ml-1 shrink-0 text-xs">
|
|
89
|
+
<ToolStatusBadge v-if="error" status="error" />
|
|
90
|
+
<RightLine v-else-if="isExpandable" :size="14" class="text-fg-faint transition-transform duration-200" :class="isOpen ? 'rotate-90' : ''" />
|
|
91
|
+
<span v-else-if="!loading && trailing" class="text-fg-subtle">{{ trailing }}</span>
|
|
92
|
+
</span>
|
|
93
|
+
<div class="flex-1"></div>
|
|
94
|
+
</div>
|
|
95
|
+
<div v-if="isExpandable" class="functional-block-body" :class="isOpen ? 'is-open' : ''">
|
|
96
|
+
<div class="overflow-hidden">
|
|
97
|
+
<div class="mb-1 overflow-hidden rounded-md bg-overlay/6">
|
|
98
|
+
<div ref="bodyScroll" class="max-h-80 overflow-y-auto py-0.5">
|
|
99
|
+
<pre v-if="errorText" class="whitespace-pre-wrap px-3 py-1.5 font-mono text-xs text-on-danger-muted">{{ errorText }}</pre>
|
|
100
|
+
<slot v-if="hasBodySlot()" name="body" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.functional-block-body {
|
|
110
|
+
display: grid;
|
|
111
|
+
grid-template-rows: 0fr;
|
|
112
|
+
transition: grid-template-rows 0.2s ease;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.functional-block-body.is-open {
|
|
116
|
+
grid-template-rows: 1fr;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-center px-[var(--agent-pad-x,2rem)] py-2">
|
|
3
|
+
<div class="loading-dots flex gap-1">
|
|
4
|
+
<span class="size-1.5 rounded-full bg-fg-subtle" />
|
|
5
|
+
<span class="size-1.5 rounded-full bg-fg-subtle" />
|
|
6
|
+
<span class="size-1.5 rounded-full bg-fg-subtle" />
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<style scoped>
|
|
12
|
+
.loading-dots span {
|
|
13
|
+
animation: dot-pulse 1.4s ease-in-out infinite;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.loading-dots span:nth-child(2) {
|
|
17
|
+
animation-delay: 0.2s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.loading-dots span:nth-child(3) {
|
|
21
|
+
animation-delay: 0.4s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@keyframes dot-pulse {
|
|
25
|
+
0%, 80%, 100% { opacity: 0.3; }
|
|
26
|
+
40% { opacity: 1; }
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
import { BrainLine } from '@mingcute/vue/brain'
|
|
4
|
+
import { md } from '@demicodes/web-ui/markdown/md'
|
|
5
|
+
import { t } from '@demicodes/web-ui/infra/i18n'
|
|
6
|
+
import FunctionalBlock from './FunctionalBlock.vue'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
thinking: string
|
|
10
|
+
isStreaming: boolean
|
|
11
|
+
createdAt: string
|
|
12
|
+
/** Start of the block after this one — the moment thinking ended. Null while still thinking. */
|
|
13
|
+
endedAt?: string | null
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const hasContent = computed(() => props.thinking.trim().length > 0)
|
|
17
|
+
const isOpen = ref(false)
|
|
18
|
+
const renderedMarkdown = computed(() => md.render(props.thinking))
|
|
19
|
+
|
|
20
|
+
// Live timer while thinking; once done the elapsed is frozen to (next block's createdAt - this
|
|
21
|
+
// block's createdAt), so the duration survives reload instead of growing from the original time.
|
|
22
|
+
const nowMs = ref(Date.now())
|
|
23
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
24
|
+
function stopTimer() {
|
|
25
|
+
if (timer) {
|
|
26
|
+
clearInterval(timer)
|
|
27
|
+
timer = undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
watch(
|
|
31
|
+
() => props.isStreaming,
|
|
32
|
+
(streaming) => {
|
|
33
|
+
stopTimer()
|
|
34
|
+
if (streaming) {
|
|
35
|
+
nowMs.value = Date.now()
|
|
36
|
+
timer = setInterval(() => {
|
|
37
|
+
nowMs.value = Date.now()
|
|
38
|
+
}, 1000)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{ immediate: true },
|
|
42
|
+
)
|
|
43
|
+
onUnmounted(stopTimer)
|
|
44
|
+
|
|
45
|
+
const startMs = computed(() => Date.parse(props.createdAt))
|
|
46
|
+
const elapsedMs = computed(() => {
|
|
47
|
+
const end = props.endedAt ? Date.parse(props.endedAt) : props.isStreaming ? nowMs.value : null
|
|
48
|
+
if (end === null || Number.isNaN(startMs.value)) return null
|
|
49
|
+
return Math.max(0, end - startMs.value)
|
|
50
|
+
})
|
|
51
|
+
const label = computed(() => {
|
|
52
|
+
if (elapsedMs.value === null) return t('agent.block.thinking')
|
|
53
|
+
const prefix = t(props.isStreaming ? 'agent.block.thinkingFor' : 'agent.block.thoughtFor')
|
|
54
|
+
return `${prefix} ${formatDuration(elapsedMs.value)}`
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function formatDuration(ms: number): string {
|
|
58
|
+
const s = Math.round(ms / 1000)
|
|
59
|
+
if (s < 60) return `${s}s`
|
|
60
|
+
const m = Math.floor(s / 60)
|
|
61
|
+
const rs = s % 60
|
|
62
|
+
if (m < 60) return rs ? `${m}m${rs}s` : `${m}m`
|
|
63
|
+
const h = Math.floor(m / 60)
|
|
64
|
+
const rm = m % 60
|
|
65
|
+
return rm ? `${h}h${rm}m` : `${h}h`
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="px-[var(--agent-pad-x,2rem)]">
|
|
71
|
+
<FunctionalBlock
|
|
72
|
+
v-model:open="isOpen"
|
|
73
|
+
:expandable="hasContent"
|
|
74
|
+
:open-while="isStreaming && hasContent"
|
|
75
|
+
:stick-bottom="isStreaming"
|
|
76
|
+
>
|
|
77
|
+
<template #icon>
|
|
78
|
+
<BrainLine :size="16" />
|
|
79
|
+
</template>
|
|
80
|
+
<span class="min-w-0 truncate" :class="isStreaming ? 'thinking-shimmer' : ''">{{ label }}</span>
|
|
81
|
+
<template v-if="hasContent" #body>
|
|
82
|
+
<div class="markdown-body px-3 py-1 text-[13px] leading-relaxed text-fg-muted" v-html="renderedMarkdown" />
|
|
83
|
+
</template>
|
|
84
|
+
</FunctionalBlock>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { parse, Allow } from 'partial-json'
|
|
4
|
+
import type { ToolCallBlock } from '../block-types'
|
|
5
|
+
import ToolShellBlock from './ToolShellBlock.vue'
|
|
6
|
+
import ToolShellStatusBlock from './ToolShellStatusBlock.vue'
|
|
7
|
+
import ToolShellWriteBlock from './ToolShellWriteBlock.vue'
|
|
8
|
+
import ToolShellAbortBlock from './ToolShellAbortBlock.vue'
|
|
9
|
+
import ToolYieldBlock from './ToolYieldBlock.vue'
|
|
10
|
+
import ToolGenericBlock from './ToolGenericBlock.vue'
|
|
11
|
+
import { shouldParsePartialToolInput, toolRenderKind } from '../tool-rendering'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
block: ToolCallBlock
|
|
15
|
+
conversationId: string
|
|
16
|
+
isStreaming: boolean
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const parsedInput = computed<Record<string, unknown>>(() => {
|
|
20
|
+
if (!props.block.input) return {}
|
|
21
|
+
try {
|
|
22
|
+
const result = shouldParsePartialToolInput(props.block.toolName)
|
|
23
|
+
? parse(props.block.input, Allow.ALL)
|
|
24
|
+
: JSON.parse(props.block.input)
|
|
25
|
+
return typeof result === 'object' && result !== null ? result as Record<string, unknown> : {}
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
const renderKind = computed(() => toolRenderKind(props.block.toolName))
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<ToolShellBlock v-if="renderKind === 'shell_exec'" :block="block" :input="parsedInput" :is-streaming="isStreaming" />
|
|
35
|
+
<ToolShellStatusBlock v-else-if="renderKind === 'shell_status'" :block="block" :input="parsedInput" />
|
|
36
|
+
<ToolShellWriteBlock v-else-if="renderKind === 'shell_write'" :block="block" :input="parsedInput" />
|
|
37
|
+
<ToolShellAbortBlock v-else-if="renderKind === 'shell_abort'" :block="block" :input="parsedInput" />
|
|
38
|
+
<ToolYieldBlock v-else-if="renderKind === 'yield'" :block="block" :input="parsedInput" />
|
|
39
|
+
<ToolGenericBlock v-else :block="block" :input="parsedInput" />
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { FlashLine } from '@mingcute/vue/flash'
|
|
4
|
+
import FunctionalBlock from './FunctionalBlock.vue'
|
|
5
|
+
import type { ToolCallBlock } from '../block-types'
|
|
6
|
+
import { getToolErrorText } from '../block-helpers'
|
|
7
|
+
import { trimToolSummary } from '../tool-rendering'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
block: ToolCallBlock
|
|
11
|
+
input: Record<string, unknown>
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const summary = computed(() => {
|
|
15
|
+
const entries = Object.entries(props.input)
|
|
16
|
+
if (entries.length === 0) return ''
|
|
17
|
+
return entries
|
|
18
|
+
.map(([k, v]) => {
|
|
19
|
+
const val = typeof v === 'string' ? v : JSON.stringify(v)
|
|
20
|
+
const short = val.length > 40 ? `${val.slice(0, 37)}...` : val
|
|
21
|
+
return `${k}=${short}`
|
|
22
|
+
})
|
|
23
|
+
.join(' ')
|
|
24
|
+
})
|
|
25
|
+
const errorText = computed(() => getToolErrorText(props.block))
|
|
26
|
+
const detail = computed(() => {
|
|
27
|
+
const text = errorText.value
|
|
28
|
+
return text ? trimToolSummary(text, 160) : summary.value
|
|
29
|
+
})
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<FunctionalBlock
|
|
34
|
+
:label="block.toolName"
|
|
35
|
+
:detail="detail"
|
|
36
|
+
:loading="block.status === 'executing'"
|
|
37
|
+
:error="block.status === 'error'"
|
|
38
|
+
>
|
|
39
|
+
<template #icon>
|
|
40
|
+
<FlashLine :size="16" />
|
|
41
|
+
</template>
|
|
42
|
+
</FunctionalBlock>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ToolCallBlock } from '../block-types'
|
|
3
|
+
import ToolShellControlBlock from './ToolShellControlBlock.vue'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
block: ToolCallBlock
|
|
7
|
+
input: Record<string, unknown>
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<ToolShellControlBlock :block="block" :input="input" tool-name="shell_abort" />
|
|
13
|
+
</template>
|