@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,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import dayjs from 'dayjs'
|
|
4
|
+
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
5
|
+
import { HistoryLine } from '@mingcute/vue/history'
|
|
6
|
+
import { Chat1Line } from '@mingcute/vue/chat-1'
|
|
7
|
+
|
|
8
|
+
dayjs.extend(relativeTime)
|
|
9
|
+
import { t } from '@demicodes/web-ui/infra/i18n'
|
|
10
|
+
import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
|
|
11
|
+
import HighlightText from '@demicodes/web-ui/ui/HighlightText.vue'
|
|
12
|
+
import SelectDropdown from '@demicodes/web-ui/ui/SelectDropdown.vue'
|
|
13
|
+
|
|
14
|
+
// Open tabs and server-side history summaries both satisfy this shape.
|
|
15
|
+
interface ConversationListItem {
|
|
16
|
+
id: string
|
|
17
|
+
title: string
|
|
18
|
+
createdAt: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
conversations: ConversationListItem[]
|
|
23
|
+
activeTabId: string | null
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
select: [conversationId: string]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const items = computed(() =>
|
|
31
|
+
props.conversations.map((c) => ({ id: c.id, label: c.title, createdAt: c.createdAt })),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
function formatTime(iso: string): string {
|
|
35
|
+
return dayjs(iso).fromNow()
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<SelectDropdown
|
|
41
|
+
:overlay-store="appOverlayStore"
|
|
42
|
+
:items="items"
|
|
43
|
+
:selected-id="activeTabId ?? undefined"
|
|
44
|
+
searchable
|
|
45
|
+
:search-placeholder="t('agent.conversationList.placeholder')"
|
|
46
|
+
:empty-text="t('agent.conversationList.empty')"
|
|
47
|
+
panel-class="w-96"
|
|
48
|
+
:item-height="32"
|
|
49
|
+
placement="bottom-end"
|
|
50
|
+
:offset="6"
|
|
51
|
+
@select="emit('select', $event)"
|
|
52
|
+
>
|
|
53
|
+
<template #trigger="{ isOpen }">
|
|
54
|
+
<span
|
|
55
|
+
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-hover hover:text-fg-muted"
|
|
56
|
+
:class="isOpen && 'bg-overlay/6 text-fg-muted'"
|
|
57
|
+
>
|
|
58
|
+
<HistoryLine :size="14" />
|
|
59
|
+
</span>
|
|
60
|
+
</template>
|
|
61
|
+
<template #item="{ item, query }">
|
|
62
|
+
<Chat1Line :size="13" class="shrink-0" />
|
|
63
|
+
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
|
|
64
|
+
<span class="truncate text-[13px]">
|
|
65
|
+
<HighlightText :text="item.label" :query="query" />
|
|
66
|
+
</span>
|
|
67
|
+
<span class="shrink-0 text-[11px] text-fg-faint">{{ formatTime(item.createdAt) }}</span>
|
|
68
|
+
</span>
|
|
69
|
+
</template>
|
|
70
|
+
</SelectDropdown>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { ConversationStatus } from './conversation-status'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
status: ConversationStatus
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const dotColor = computed(() => {
|
|
10
|
+
if (props.status === 'active') return 'var(--color-blue-400)'
|
|
11
|
+
if (props.status === 'error' || props.status === 'aborted') return 'var(--color-red-400)'
|
|
12
|
+
if (props.status === 'done') return 'var(--color-emerald-400)'
|
|
13
|
+
return null
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<span
|
|
19
|
+
class="absolute -right-px -top-px size-1.5 rounded-full border transition-[background-color,opacity] duration-300"
|
|
20
|
+
:class="[
|
|
21
|
+
dotColor ? 'opacity-100 border-surface-base' : 'opacity-0 border-transparent',
|
|
22
|
+
status === 'active' && 'animate-pulse',
|
|
23
|
+
]"
|
|
24
|
+
:style="{ backgroundColor: dotColor ?? 'transparent' }"
|
|
25
|
+
/>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { useElementSize } from '@vueuse/core'
|
|
4
|
+
import { useAgentWorkspace } from './workspace'
|
|
5
|
+
import AgentMessageList from './AgentMessageList.vue'
|
|
6
|
+
import AgentMessageInput from './AgentMessageInput.vue'
|
|
7
|
+
import MessageQueueBar from './MessageQueueBar.vue'
|
|
8
|
+
import { queuedMessageIdForEmptySubmit } from './queue-submit'
|
|
9
|
+
import { reportError } from '@demicodes/web-ui/infra/errors'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
conversationId: string
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const workspace = useAgentWorkspace()
|
|
16
|
+
const session = computed(() => workspace.sessions[props.conversationId])
|
|
17
|
+
const blocks = computed(() => session.value?.blocks ?? [])
|
|
18
|
+
const queuedMessages = computed(() => session.value?.queue ?? [])
|
|
19
|
+
const pendingSteers = computed(() => session.value?.pendingSteers ?? [])
|
|
20
|
+
const phase = computed(() => session.value?.phase ?? 'idle')
|
|
21
|
+
|
|
22
|
+
const bottomAreaRef = ref<HTMLDivElement>()
|
|
23
|
+
const messageInputRef = ref<InstanceType<typeof AgentMessageInput>>()
|
|
24
|
+
const { height: bottomAreaHeight } = useElementSize(bottomAreaRef)
|
|
25
|
+
|
|
26
|
+
function handleEmptySubmit() {
|
|
27
|
+
const messageId = queuedMessageIdForEmptySubmit(queuedMessages.value)
|
|
28
|
+
if (!messageId) return
|
|
29
|
+
handleQueuedSendNow(messageId)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleQueuedSendNow(messageId: string) {
|
|
33
|
+
if (phase.value === 'running') {
|
|
34
|
+
void workspace.steerQueuedMessage(props.conversationId, messageId).catch((error) => {
|
|
35
|
+
reportError('Failed to steer queued message', error, { userVisible: true })
|
|
36
|
+
})
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
workspace.sendQueuedMessage(props.conversationId, messageId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onContinue() {
|
|
43
|
+
void workspace.resume(props.conversationId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onRetry() {
|
|
47
|
+
void workspace.retry(props.conversationId)
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<div class="relative h-full flex-1 overflow-hidden bg-surface">
|
|
53
|
+
<AgentMessageList
|
|
54
|
+
:conversation-id="conversationId"
|
|
55
|
+
:blocks="blocks"
|
|
56
|
+
:pending-steers="pendingSteers"
|
|
57
|
+
:phase="phase"
|
|
58
|
+
:bottom-offset="bottomAreaHeight"
|
|
59
|
+
:persisted-scroll-state="undefined"
|
|
60
|
+
@continue="onContinue"
|
|
61
|
+
@retry="onRetry"
|
|
62
|
+
@delete-pending-steer="(steerId) => workspace.deletePendingSteer(conversationId, steerId)"
|
|
63
|
+
/>
|
|
64
|
+
<div class="absolute bottom-0 left-0 right-0 z-10 px-3 pb-3">
|
|
65
|
+
<div ref="bottomAreaRef" class="relative">
|
|
66
|
+
<MessageQueueBar
|
|
67
|
+
v-if="queuedMessages.length"
|
|
68
|
+
:messages="queuedMessages"
|
|
69
|
+
@remove="(messageId) => workspace.dequeueMessage(conversationId, messageId)"
|
|
70
|
+
@send-now="handleQueuedSendNow"
|
|
71
|
+
@clear-all="workspace.clearMessageQueue(conversationId)"
|
|
72
|
+
/>
|
|
73
|
+
<AgentMessageInput
|
|
74
|
+
ref="messageInputRef"
|
|
75
|
+
class="relative z-10"
|
|
76
|
+
:conversation-id="conversationId"
|
|
77
|
+
@empty-submit="handleEmptySubmit"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { DeleteLine } from '@mingcute/vue/delete'
|
|
3
|
+
import { ListCheckLine } from '@mingcute/vue/list-check'
|
|
4
|
+
import { SendLine } from '@mingcute/vue/send'
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
messages: { id: string; text: string }[]
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
remove: [id: string]
|
|
12
|
+
sendNow: [id: string]
|
|
13
|
+
clearAll: []
|
|
14
|
+
}>()
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="relative mx-3 -mb-3">
|
|
19
|
+
<div class="rounded-t-lg bg-surface-raised border border-line-subtle border-b-0 pb-4.5">
|
|
20
|
+
<div class="flex flex-col px-1 pt-1.5">
|
|
21
|
+
<div class="flex items-center gap-2 px-1.5 pb-1">
|
|
22
|
+
<div class="flex flex-1 items-center gap-1.5 text-[12px] text-fg-subtle">
|
|
23
|
+
<ListCheckLine :size="14" />
|
|
24
|
+
<span>{{ messages.length }} Queued</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="shrink-0 flex items-center gap-0.5">
|
|
27
|
+
<span
|
|
28
|
+
class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
|
|
29
|
+
@click="emit('clearAll')"
|
|
30
|
+
>
|
|
31
|
+
<DeleteLine :size="14" />
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="flex flex-col">
|
|
36
|
+
<div
|
|
37
|
+
v-for="(message, index) in messages"
|
|
38
|
+
:key="message.id"
|
|
39
|
+
class="flex items-center gap-2 px-1.5"
|
|
40
|
+
>
|
|
41
|
+
<span class="shrink-0 text-[12px] tabular-nums text-fg-faint">{{ index + 1 }}</span>
|
|
42
|
+
<div
|
|
43
|
+
class="flex min-w-0 flex-1 items-center gap-2 py-1"
|
|
44
|
+
:class="index < messages.length - 1 ? 'border-b border-line-subtle' : ''"
|
|
45
|
+
>
|
|
46
|
+
<span class="min-w-0 flex-1 truncate text-[13px] text-fg-muted">{{ message.text }}</span>
|
|
47
|
+
<div class="shrink-0 flex items-center gap-0.5">
|
|
48
|
+
<span
|
|
49
|
+
class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
|
|
50
|
+
@click="emit('sendNow', message.id)"
|
|
51
|
+
>
|
|
52
|
+
<SendLine :size="14" />
|
|
53
|
+
</span>
|
|
54
|
+
<span
|
|
55
|
+
class="flex size-6 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:bg-active hover:text-fg-body"
|
|
56
|
+
@click="emit('remove', message.id)"
|
|
57
|
+
>
|
|
58
|
+
<DeleteLine :size="14" />
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import type { ThinkingConfig } from '@demicodes/core'
|
|
4
|
+
import type { ModelInfo, ProviderInfo } from '../transport/protocol'
|
|
5
|
+
import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
|
|
6
|
+
import { buildReasoningState } from './reasoning'
|
|
7
|
+
import DropdownMenu from '@demicodes/web-ui/ui/DropdownMenu.vue'
|
|
8
|
+
import SelectorTrigger from './SelectorTrigger.vue'
|
|
9
|
+
import ReasoningSelector from './ReasoningSelector.vue'
|
|
10
|
+
import OptionMenu from '@demicodes/web-ui/ui/OptionMenu.vue'
|
|
11
|
+
import OptionMenuGroup from '@demicodes/web-ui/ui/OptionMenuGroup.vue'
|
|
12
|
+
import OptionMenuItem from '@demicodes/web-ui/ui/OptionMenuItem.vue'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
providers: ProviderInfo[]
|
|
16
|
+
models: Record<string, ModelInfo[]>
|
|
17
|
+
selectedProviderId?: string | null
|
|
18
|
+
selectedModelId?: string | null
|
|
19
|
+
thinkingConfig?: ThinkingConfig
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
selectModel: [providerId: string, modelId: string]
|
|
24
|
+
changeThinking: [config: ThinkingConfig]
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
interface SelectedModel {
|
|
28
|
+
providerId: string
|
|
29
|
+
modelId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const selectedModel = ref<SelectedModel | null>(null)
|
|
33
|
+
|
|
34
|
+
const providerModels = computed(() => props.models)
|
|
35
|
+
|
|
36
|
+
const availableProviders = computed(() =>
|
|
37
|
+
props.providers.filter(p => p.isAvailable && (providerModels.value[p.id]?.length ?? 0) > 0),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const hasModels = computed(() =>
|
|
41
|
+
availableProviders.value.some(p => (providerModels.value[p.id] ?? []).length > 0),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const selectedModelPreset = computed(() => {
|
|
45
|
+
const selected = selectedModel.value
|
|
46
|
+
if (!selected) return null
|
|
47
|
+
return (providerModels.value[selected.providerId] ?? []).find(m => m.id === selected.modelId) ?? null
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const selectedModelLabel = computed(() => selectedModelPreset.value?.name ?? '')
|
|
51
|
+
|
|
52
|
+
const reasoningState = computed(() => buildReasoningState(selectedModelPreset.value))
|
|
53
|
+
|
|
54
|
+
function isSelectedModel(providerId: string, modelId: string): boolean {
|
|
55
|
+
return selectedModel.value?.providerId === providerId
|
|
56
|
+
&& selectedModel.value?.modelId === modelId
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function selectModel(providerId: string, modelId: string) {
|
|
60
|
+
selectedModel.value = { providerId, modelId }
|
|
61
|
+
emit('selectModel', providerId, modelId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveFallbackSelection(): SelectedModel | null {
|
|
65
|
+
for (const provider of availableProviders.value) {
|
|
66
|
+
const firstModel = providerModels.value[provider.id]?.[0]
|
|
67
|
+
if (firstModel) return { providerId: provider.id, modelId: firstModel.id }
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
watch(
|
|
73
|
+
[
|
|
74
|
+
() => props.selectedProviderId,
|
|
75
|
+
() => props.selectedModelId,
|
|
76
|
+
providerModels,
|
|
77
|
+
availableProviders,
|
|
78
|
+
],
|
|
79
|
+
() => {
|
|
80
|
+
if (props.selectedProviderId && props.selectedModelId) {
|
|
81
|
+
selectedModel.value = { providerId: props.selectedProviderId, modelId: props.selectedModelId }
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
selectedModel.value = resolveFallbackSelection()
|
|
85
|
+
},
|
|
86
|
+
{ immediate: true, deep: true },
|
|
87
|
+
)
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<template v-if="hasModels">
|
|
92
|
+
<DropdownMenu :overlay-store="appOverlayStore">
|
|
93
|
+
<template #trigger="{ isOpen }">
|
|
94
|
+
<SelectorTrigger :is-open="isOpen">
|
|
95
|
+
{{ selectedModelLabel }}
|
|
96
|
+
</SelectorTrigger>
|
|
97
|
+
</template>
|
|
98
|
+
<template #content="{ close }">
|
|
99
|
+
<OptionMenu>
|
|
100
|
+
<OptionMenuGroup
|
|
101
|
+
v-for="provider in availableProviders"
|
|
102
|
+
:key="provider.id"
|
|
103
|
+
:label="provider.label"
|
|
104
|
+
>
|
|
105
|
+
<OptionMenuItem
|
|
106
|
+
v-for="model in providerModels[provider.id] ?? []"
|
|
107
|
+
:key="`${provider.id}:${model.id}`"
|
|
108
|
+
:label="model.name"
|
|
109
|
+
:is-selected="isSelectedModel(provider.id, model.id)"
|
|
110
|
+
@select="selectModel(provider.id, model.id); close()"
|
|
111
|
+
/>
|
|
112
|
+
</OptionMenuGroup>
|
|
113
|
+
</OptionMenu>
|
|
114
|
+
</template>
|
|
115
|
+
</DropdownMenu>
|
|
116
|
+
<template v-if="reasoningState">
|
|
117
|
+
<span class="h-3 w-px bg-overlay/10" />
|
|
118
|
+
<ReasoningSelector
|
|
119
|
+
:reasoning-state="reasoningState"
|
|
120
|
+
v-bind="thinkingConfig ? { config: thinkingConfig } : {}"
|
|
121
|
+
@change="emit('changeThinking', $event)"
|
|
122
|
+
/>
|
|
123
|
+
</template>
|
|
124
|
+
</template>
|
|
125
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, watchEffect } from 'vue'
|
|
3
|
+
import type { ThinkingConfig } from '@demicodes/core'
|
|
4
|
+
import type { ReasoningState } from './reasoning'
|
|
5
|
+
import { BrainLine } from '@mingcute/vue/brain'
|
|
6
|
+
import { appOverlayStore } from '@demicodes/web-ui/overlay/appOverlay'
|
|
7
|
+
import { t } from '@demicodes/web-ui/infra/i18n'
|
|
8
|
+
import Switch from '@demicodes/web-ui/ui/Switch.vue'
|
|
9
|
+
import DropdownMenu from '@demicodes/web-ui/ui/DropdownMenu.vue'
|
|
10
|
+
import SelectorTrigger from './SelectorTrigger.vue'
|
|
11
|
+
import OptionMenu from '@demicodes/web-ui/ui/OptionMenu.vue'
|
|
12
|
+
import OptionMenuGroup from '@demicodes/web-ui/ui/OptionMenuGroup.vue'
|
|
13
|
+
import OptionMenuItem from '@demicodes/web-ui/ui/OptionMenuItem.vue'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
reasoningState: ReasoningState
|
|
17
|
+
config?: ThinkingConfig
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
change: [config: ThinkingConfig]
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const resolvedConfig = computed(() => {
|
|
25
|
+
const cfg = props.config ?? props.reasoningState.defaultConfig
|
|
26
|
+
// Models that can't disable thinking (e.g. Claude Code) have no "disabled" state — a stale or
|
|
27
|
+
// carried-over disabled config resolves to the default effort so the label and the request match.
|
|
28
|
+
if (!props.reasoningState.canDisable && cfg.type === 'disabled') return props.reasoningState.defaultConfig
|
|
29
|
+
return cfg
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Persist that coercion upward so the next request actually sends an effort, not a no-op "disabled".
|
|
33
|
+
watchEffect(() => {
|
|
34
|
+
if (!props.reasoningState.canDisable && props.config?.type === 'disabled') {
|
|
35
|
+
emit('change', props.reasoningState.defaultConfig)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const isThinkingEnabled = computed(() => resolvedConfig.value.type !== 'disabled')
|
|
40
|
+
|
|
41
|
+
function toggleThinking(enabled: boolean) {
|
|
42
|
+
emit('change', enabled ? props.reasoningState.defaultConfig : { type: 'disabled' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSelected(optionConfig: ThinkingConfig): boolean {
|
|
46
|
+
const cfg = resolvedConfig.value
|
|
47
|
+
if (cfg.type !== optionConfig.type) return false
|
|
48
|
+
if (cfg.type === 'adaptive' && optionConfig.type === 'adaptive') return cfg.effort === optionConfig.effort
|
|
49
|
+
if (cfg.type === 'effort' && optionConfig.type === 'effort') return cfg.effort === optionConfig.effort
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const defaultOption = computed(() => props.reasoningState.options.find((o) => isSelected(o.config)))
|
|
54
|
+
|
|
55
|
+
const currentDropdownLabel = computed(() => {
|
|
56
|
+
if (defaultOption.value) return defaultOption.value.label
|
|
57
|
+
const fallback = props.reasoningState.options.find((o) =>
|
|
58
|
+
o.config.type === props.reasoningState.defaultConfig.type,
|
|
59
|
+
)
|
|
60
|
+
return fallback?.label ?? ''
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function selectOption(config: ThinkingConfig, close: () => void) {
|
|
64
|
+
emit('change', config)
|
|
65
|
+
close()
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<Switch
|
|
71
|
+
v-if="reasoningState.mode === 'toggle'"
|
|
72
|
+
:model-value="isThinkingEnabled"
|
|
73
|
+
:label="t('providers.model.thinking')"
|
|
74
|
+
size="sm"
|
|
75
|
+
@update:model-value="toggleThinking"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<DropdownMenu v-else-if="reasoningState.mode === 'dropdown'" :overlay-store="appOverlayStore">
|
|
79
|
+
<template #trigger="{ isOpen }">
|
|
80
|
+
<SelectorTrigger :is-open="isOpen">
|
|
81
|
+
<BrainLine :size="13" class="mr-0.5" />
|
|
82
|
+
{{ currentDropdownLabel }}
|
|
83
|
+
</SelectorTrigger>
|
|
84
|
+
</template>
|
|
85
|
+
<template #content="{ close }">
|
|
86
|
+
<OptionMenu>
|
|
87
|
+
<OptionMenuItem
|
|
88
|
+
v-for="option in reasoningState.options.filter(o => o.group === 'general')"
|
|
89
|
+
:key="option.label"
|
|
90
|
+
:label="option.label"
|
|
91
|
+
:is-selected="isSelected(option.config)"
|
|
92
|
+
@select="selectOption(option.config, close)"
|
|
93
|
+
/>
|
|
94
|
+
<OptionMenuGroup :label="t('providers.reasoning')">
|
|
95
|
+
<OptionMenuItem
|
|
96
|
+
v-for="option in reasoningState.options.filter(o => o.group === 'effort')"
|
|
97
|
+
:key="option.label"
|
|
98
|
+
:label="option.label"
|
|
99
|
+
:is-selected="isSelected(option.config)"
|
|
100
|
+
@select="selectOption(option.config, close)"
|
|
101
|
+
/>
|
|
102
|
+
</OptionMenuGroup>
|
|
103
|
+
</OptionMenu>
|
|
104
|
+
</template>
|
|
105
|
+
</DropdownMenu>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RightSmallLine } from '@mingcute/vue/right-small'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
isOpen: boolean
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<span
|
|
11
|
+
class="flex cursor-pointer items-center gap-0.5 rounded-md pl-2 pr-0.5 py-1 text-[12px] transition-colors"
|
|
12
|
+
:class="isOpen ? 'bg-overlay/5 text-fg-muted' : 'text-fg-subtle hover:bg-hover hover:text-fg-muted'"
|
|
13
|
+
>
|
|
14
|
+
<slot />
|
|
15
|
+
<RightSmallLine :size="16" class="rotate-90" />
|
|
16
|
+
</span>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import type { Block, ModelSelection } from '@demicodes/core'
|
|
3
|
+
import { shellTerminalOutputChunks } from '../block-helpers'
|
|
4
|
+
|
|
5
|
+
test('shell terminal output renders the view chunks', () => {
|
|
6
|
+
const block = tool({
|
|
7
|
+
view: {
|
|
8
|
+
kind: 'shell',
|
|
9
|
+
chunks: [
|
|
10
|
+
{ stream: 'stdout', text: 'first stdout\n' },
|
|
11
|
+
{ stream: 'stderr', text: 'first stderr\n' },
|
|
12
|
+
{ stream: 'stdout', text: 'second stdout\n' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
expect(shellTerminalOutputChunks(block)).toEqual([
|
|
18
|
+
{ stream: 'stdout', text: 'first stdout\n' },
|
|
19
|
+
{ stream: 'stderr', text: 'first stderr\n' },
|
|
20
|
+
{ stream: 'stdout', text: 'second stdout\n' },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('shell terminal output skips empty and malformed chunks', () => {
|
|
25
|
+
const block = tool({
|
|
26
|
+
view: {
|
|
27
|
+
kind: 'shell',
|
|
28
|
+
chunks: [
|
|
29
|
+
{ stream: 'stdout', text: '' },
|
|
30
|
+
{ stream: 'other', text: 'not a stream' },
|
|
31
|
+
'garbage',
|
|
32
|
+
{ stream: 'stderr', text: 'kept\n' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(shellTerminalOutputChunks(block)).toEqual([{ stream: 'stderr', text: 'kept\n' }])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('shell terminal output is empty without a chunked view', () => {
|
|
41
|
+
expect(shellTerminalOutputChunks(tool({}))).toEqual([])
|
|
42
|
+
expect(shellTerminalOutputChunks(tool({ view: { kind: 'yield_wakeup' } }))).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function tool(options: { view?: unknown }): Extract<Block, { type: 'tool_call' }> {
|
|
46
|
+
return {
|
|
47
|
+
type: 'tool_call',
|
|
48
|
+
id: 'tool-1',
|
|
49
|
+
createdAt: '1970-01-01T00:00:00.000Z',
|
|
50
|
+
model,
|
|
51
|
+
toolUseId: 'tool-1',
|
|
52
|
+
toolName: 'shell_exec',
|
|
53
|
+
input: '{}',
|
|
54
|
+
status: 'completed',
|
|
55
|
+
streamingOutput: [],
|
|
56
|
+
output: [{ type: 'text', text: 'status: exited' }],
|
|
57
|
+
view: options.view ?? null,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const model: ModelSelection = {
|
|
62
|
+
providerId: 'test',
|
|
63
|
+
model: {
|
|
64
|
+
id: 'test-model',
|
|
65
|
+
name: 'Test Model',
|
|
66
|
+
contextWindow: 1000,
|
|
67
|
+
inputLimit: null,
|
|
68
|
+
thinking: [],
|
|
69
|
+
acceptedExtensions: [],
|
|
70
|
+
},
|
|
71
|
+
thinking: null,
|
|
72
|
+
serviceTierId: null,
|
|
73
|
+
}
|