Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions dashboard/src/components/chat/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:token-usage="tokenUsageIndicator"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
Expand Down Expand Up @@ -440,6 +441,7 @@
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:token-usage="tokenUsageIndicator"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
Expand Down Expand Up @@ -561,7 +563,7 @@ import {
Sun,
Trash2,
} from "@lucide/vue";
import { chatApi } from "@/api/v1";
import { chatApi, providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProjectDialog, {
type ProjectFormData,
Expand Down Expand Up @@ -597,6 +599,12 @@ import {
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
formatTokenCount,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
import { useToast } from "@/utils/toast";

const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
Expand Down Expand Up @@ -652,6 +660,11 @@ const {

type WorkspaceView = "chat" | "providers";

interface TokenProviderConfig extends ProviderMetadataSource {
id: string;
enable?: boolean;
}

const activeWorkspace = ref<WorkspaceView>("chat");
const projectDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
Expand All @@ -670,6 +683,9 @@ const projectSessionsById = ref<Record<string, Session[]>>({});
const loadingProjectSessionIds = ref<string[]>([]);
const loadingSessions = ref(false);
const draft = ref("");
const tokenProviderConfigs = ref<TokenProviderConfig[]>([]);
const tokenModelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const selectedTokenProviderId = ref("");
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const shouldStickToBottom = ref(true);
Expand Down Expand Up @@ -841,15 +857,58 @@ const chatInputReplyTarget = computed(() =>
selectedText: replyPreview(replyTarget.value.id, ""),
},
);
const currentTokenProvider = computed(() => {
const selectedProvider = tokenProviderConfigs.value.find(
(provider) => provider.id === selectedTokenProviderId.value,
);
return selectedProvider || tokenProviderConfigs.value[0] || null;
Comment on lines +860 to +864

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Keep selectedTokenProviderId in sync when falling back to the first provider.

In currentTokenProvider, when selectedTokenProviderId doesn't match any loaded provider, you fall back to tokenProviderConfigs[0] but leave selectedTokenProviderId pointing to a non-existent provider. This desynchronizes the persisted selection (e.g. localStorage) from the actual provider used for contextLimit. Please either update selectedTokenProviderId to the fallback provider’s id when no match is found, or introduce a derived effectiveSelectedProviderId that always reflects the provider actually used.

Suggested implementation:

const tokenProviderConfigs = ref<TokenProviderConfig[]>([]);
const selectedTokenProviderId = ref("");

const effectiveSelectedTokenProviderId = computed(() => {
  const hasSelectedProvider = tokenProviderConfigs.value.some(
    (provider) => provider.id === selectedTokenProviderId.value,
  );

  if (hasSelectedProvider) {
    return selectedTokenProviderId.value;
  }

  return tokenProviderConfigs.value[0]?.id ?? "";
});

const messagesContainer = ref<HTMLElement | null>(null);

const currentTokenProvider = computed(() => {
  const selectedProvider = tokenProviderConfigs.value.find(
    (provider) => provider.id === effectiveSelectedTokenProviderId.value,
  );

  return selectedProvider || tokenProviderConfigs.value[0] || null;
});

Any logic that depends on the “actually used” token provider (e.g. for contextLimit, pricing display, or usage calculation) should use effectiveSelectedTokenProviderId or currentTokenProvider rather than selectedTokenProviderId. Search for usages of selectedTokenProviderId elsewhere in Chat.vue (and related components) and update them where they should reflect the effective provider, not just the persisted/raw selection.

});
Comment on lines +860 to +865

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The selectedTokenProviderId is only updated when getSelectedProviderSelection() is called (which happens during message sending or editing) or on initial mount. If a user changes the active provider/model in the dropdown menu, selectedTokenProviderId will not update reactively. As a result, the tokenUsageIndicator (and the progress ring) will continue to use the context limit of the old provider until a message is sent.

To fix this, consider having ProviderModelMenu emit a change event when a new provider is selected, propagating it up through ChatInput to Chat.vue to update selectedTokenProviderId reactively.

const currentTokenMetadata = computed(() => {
const model = currentTokenProvider.value?.model;
return model ? tokenModelMetadata.value[model] || null : null;
});
const latestTokenUsageTotal = computed(() => {
for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) {
const message = activeMessages.value[index];
if (isUserMessage(message)) continue;
const usage = message.content?.agentStats?.token_usage;
if (!usage) continue;
return (
readTokenCount(usage.input_other) +
readTokenCount(usage.input_cached) +
readTokenCount(usage.output)
);
}
return 0;
});
const tokenUsageIndicator = computed(() => {
const used = latestTokenUsageTotal.value;
const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value);
if (used <= 0 || limit <= 0) return null;

const percent = (used / limit) * 100;
return {
used,
limit,
percent: Math.min(100, Math.max(0, percent)),
tooltip: tm("tokenUsage.tooltip", {
used: formatTokenCount(used),
limit: formatTokenCount(limit),
percent: formatUsagePercent(percent),
}),
};
});

function getSelectedProviderSelection() {
const inputSelection = inputRef.value?.getCurrentSelection();
if (inputSelection?.providerId) {
selectedTokenProviderId.value = inputSelection.providerId;
return inputSelection;
}
if (typeof window === "undefined") {
return { providerId: "", modelName: "" };
}
syncSelectedTokenProvider();
return {
providerId: localStorage.getItem("selectedProvider") || "",
modelName: localStorage.getItem("selectedProviderModel") || "",
Expand All @@ -869,7 +928,7 @@ watch(
onMounted(async () => {
loadingSessions.value = true;
try {
await Promise.all([getSessions(), getProjects()]);
await Promise.all([getSessions(), getProjects(), loadTokenProviders()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = "providers";
Expand Down Expand Up @@ -954,6 +1013,40 @@ function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("conversation.newConversation");
}

function syncSelectedTokenProvider() {
if (typeof window === "undefined") return;
selectedTokenProviderId.value = localStorage.getItem("selectedProvider") || "";
}

async function loadTokenProviders() {
syncSelectedTokenProvider();
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
tokenModelMetadata.value = (
(response.data as any).model_metadata || {}
) as Record<string, ProviderModelMetadata>;
tokenProviderConfigs.value = (
(response.data.data || []) as unknown as TokenProviderConfig[]
).filter((provider) => provider.enable !== false);
Comment on lines +1029 to +1031

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Some providers might use the enabled property instead of enable to indicate their status (as seen in other parts of the API and configuration helpers). To ensure disabled providers are correctly filtered out, check both properties.

      tokenProviderConfigs.value = (
        (response.data.data || []) as unknown as TokenProviderConfig[]
      ).filter((provider) => provider.enable !== false && provider.enabled !== false);

}
} catch (error) {
console.error("Failed to load provider context metadata:", error);
}
}

function readTokenCount(value: unknown) {
const count = Number(value || 0);
return Number.isFinite(count) && count > 0 ? count : 0;
}

function formatUsagePercent(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0";
if (value >= 10) return String(Math.round(value));
if (value >= 1) return String(Math.round(value * 10) / 10);
return String(Math.round(value * 100) / 100);
}

async function startNewChat() {
showChatWorkspace();
selectedProjectId.value = null;
Expand Down
78 changes: 78 additions & 0 deletions dashboard/src/components/chat/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,27 @@
class="mr-1"
width="1.5"
/>
<v-tooltip
v-if="tokenUsageVisible"
location="top"
max-width="320"
>
<template #activator="{ props: tokenTooltipProps }">
<span
v-bind="tokenTooltipProps"
class="token-usage-indicator"
:style="{ '--token-usage-color': tokenUsageColor }"
>
<v-progress-circular
:model-value="tokenUsagePercent"
size="24"
width="2.5"
class="token-usage-progress"
/>
</span>
</template>
<span>{{ props.tokenUsage?.tooltip }}</span>
</v-tooltip>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
Expand Down Expand Up @@ -332,6 +353,13 @@ interface ReplyInfo {
selectedText?: string;
}

interface TokenUsageInfo {
used: number;
limit: number;
percent: number;
tooltip: string;
}

interface Props {
prompt: string;
stagedImagesUrl: string[];
Expand All @@ -347,6 +375,7 @@ interface Props {
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
showProviderSelector?: boolean;
tokenUsage?: TokenUsageInfo | null;
}

const props = withDefaults(defineProps<Props>(), {
Expand All @@ -357,6 +386,7 @@ const props = withDefaults(defineProps<Props>(), {
replyTo: null,
sendShortcut: "shift_enter",
showProviderSelector: true,
tokenUsage: null,
});

const emit = defineEmits<{
Expand Down Expand Up @@ -569,6 +599,29 @@ function handleReplyAfterLeave() {

const { mobile } = useDisplay();

const tokenUsageVisible = computed(() => {
const usage = props.tokenUsage;
return Boolean(
usage &&
Number.isFinite(usage.used) &&
Number.isFinite(usage.limit) &&
usage.used > 0 &&
usage.limit > 0,
);
});

const tokenUsagePercent = computed(() => {
const percent = props.tokenUsage?.percent || 0;
if (!Number.isFinite(percent)) return 0;
return Math.min(100, Math.max(0, percent));
});

const tokenUsageColor = computed(() =>
isDark.value
? "rgba(var(--v-theme-on-surface), 0.82)"
: "rgba(var(--v-theme-on-surface), 0.72)",
);

// Auto-resize textarea
function autoResize() {
const el = inputField.value;
Expand Down Expand Up @@ -996,6 +1049,31 @@ defineExpose({
background: rgba(var(--v-theme-on-surface), 0.04) !important;
}

.token-usage-indicator {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
border-radius: 50%;
color: var(--token-usage-color);
}

.token-usage-progress {
color: currentColor;
}

.token-usage-progress :deep(.v-progress-circular__underlay) {
color: rgba(var(--v-theme-on-surface), 0.18);
stroke: currentColor;
opacity: 0.24;
}

.token-usage-progress :deep(.v-progress-circular__overlay) {
stroke: currentColor;
}

.input-outline-control {
width: 36px !important;
height: 36px !important;
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@
"duration": "Duration",
"ttft": "Time to First Token"
},
"tokenUsage": {
"tooltip": "{used} / {limit} tokens ({percent}%)"
},
"refs": {
"title": "References",
"sources": "Sources"
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@
"duration": "Время",
"ttft": "Время до первого токена"
},
"tokenUsage": {
"tooltip": "{used} / {limit} токенов ({percent}%)"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@
"duration": "耗时",
"ttft": "首字时间"
},
"tokenUsage": {
"tooltip": "{used} / {limit} tokens({percent}%)"
},
"refs": {
"title": "引用",
"sources": "来源"
Expand Down
29 changes: 24 additions & 5 deletions dashboard/src/utils/providerMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,28 @@ export interface ProviderCapabilityBadge {
tooltip: string
}

export function contextLimit(
provider: ProviderMetadataSource | null | undefined,
metadata?: ProviderModelMetadata | null
): number {
const context = Number(metadata?.limit?.context || provider?.max_context_tokens || 0)
return Number.isFinite(context) && context > 0 ? context : 0
}

export function formatTokenCount(value: number): string {
if (!Number.isFinite(value)) return ''
const absValue = Math.abs(value)
if (absValue >= 1_000_000) return `${formatCompactNumber(value / 1_000_000)}M`
if (absValue >= 1_000) return `${formatCompactNumber(value / 1_000)}K`
return `${Math.round(value)}`
}

export function formatContextLimit(
provider: ProviderMetadataSource | null | undefined,
metadata?: ProviderModelMetadata | null
): string {
const context = metadata?.limit?.context || provider?.max_context_tokens
if (!context || typeof context !== 'number') return ''
if (context >= 1_000_000) return `${Math.round(context / 1_000_000)}M`
if (context >= 1_000) return `${Math.round(context / 1_000)}K`
return `${context}`
const context = contextLimit(provider, metadata)
return context ? formatTokenCount(context) : ''
}

export function providerCapabilityBadges(
Expand Down Expand Up @@ -81,3 +94,9 @@ export function providerCapabilityBadges(
: tm('models.metadata.enabled', { capability: item.label })
}))
}

function formatCompactNumber(value: number): string {
const absValue = Math.abs(value)
const rounded = absValue >= 10 ? Math.round(value) : Math.round(value * 10) / 10
return String(rounded).replace(/\.0$/, '')
}
Loading