[codex] Add local tool trace toggle (#806)
* test: harden tool approval browser contract * test: cover tool trace display edge cases * test: cover resumed tool trace edge cases * feat: hide tool traces by default * Add local tool trace toggle --------- Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
This commit is contained in:
@@ -8,10 +8,12 @@ import { setModelContext } from '@/api/hermes/model-context'
|
||||
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||
const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const commandDropdownRef = ref<HTMLDivElement>()
|
||||
@@ -430,6 +432,24 @@ function isImage(type: string): boolean {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="tool-trace-toggle"
|
||||
:class="{ active: toolTraceVisible }"
|
||||
:aria-label="toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls')"
|
||||
@click="toggleToolTraceVisible"
|
||||
>
|
||||
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
|
||||
</svg>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
|
||||
</NTooltip>
|
||||
|
||||
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
||||
{{ formatTokens(totalTokens) }} /
|
||||
<NTooltip trigger="hover">
|
||||
@@ -614,20 +634,65 @@ function isImage(type: string): boolean {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
padding: 0 0 0 8px;
|
||||
border-left: 1px solid $border-light;
|
||||
margin-left: 4px;
|
||||
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $text-muted;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-switch),
|
||||
:deep(.n-switch__rail) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999999;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
margin-left: -4px;
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
opacity: 1;
|
||||
|
||||
:deep(.n-button__state-border),
|
||||
:deep(.n-button__border),
|
||||
:deep(.n-button__ripple) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-trace-icon {
|
||||
display: block;
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #999999;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #999999;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.context-info {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MessageItem from "./MessageItem.vue";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||
import type { Session } from "@/stores/hermes/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,6 +11,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const { t } = useI18n();
|
||||
const listRef = ref<HTMLElement>();
|
||||
|
||||
@@ -18,10 +20,10 @@ const activeSession = computed(() => props.session || chatStore.activeSession);
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
(activeSession.value?.messages || []).filter((m) => {
|
||||
// Filter out tool messages without name (internal use only)
|
||||
if (m.role === 'tool' && !m.toolName) return false
|
||||
// Filter out messages with empty content (except tool messages)
|
||||
if (m.role !== 'tool' && !m.content?.trim()) return false
|
||||
// Tool messages without a name are internal use only and remain hidden.
|
||||
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
|
||||
// Filter out messages with empty content.
|
||||
if (!m.content?.trim()) return false
|
||||
return true
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,13 @@ import { useGlobalSpeech } from "@/composables/useSpeech";
|
||||
import { useVoiceSettings } from "@/composables/useVoiceSettings";
|
||||
import { speedToEdgeRate, hzToEdgePitch } from "@/utils/ttsHelpers";
|
||||
|
||||
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
||||
const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000;
|
||||
const JSON_STRING_DISPLAY_LIMIT = 200;
|
||||
const JSON_MAX_DEPTH = 6;
|
||||
const JSON_MAX_NODES = 1000;
|
||||
const JSON_MAX_KEYS_PER_OBJECT = 50;
|
||||
const JSON_MAX_ITEMS_PER_ARRAY = 50;
|
||||
const JSON_TRUNCATED_KEY = "__truncated__";
|
||||
|
||||
const props = defineProps<{ message: Message; highlight?: boolean }>();
|
||||
const { t } = useI18n();
|
||||
@@ -353,19 +359,96 @@ type ToolPayload = {
|
||||
language?: string;
|
||||
};
|
||||
|
||||
function truncateLongString(value: string, marker: string): string {
|
||||
return value.length > JSON_STRING_DISPLAY_LIMIT
|
||||
? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + "\n" + marker
|
||||
: value;
|
||||
}
|
||||
|
||||
function truncateJsonValue(value: unknown, marker: string): unknown {
|
||||
let nodeCount = 0;
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
function stringifyLength(candidate: unknown): number {
|
||||
return JSON.stringify(candidate, null, 2).length;
|
||||
}
|
||||
|
||||
function visit(current: unknown, depth: number): unknown {
|
||||
nodeCount += 1;
|
||||
if (nodeCount > JSON_MAX_NODES) {
|
||||
return marker;
|
||||
}
|
||||
|
||||
if (typeof current === "string") return truncateLongString(current, marker);
|
||||
if (current === null || typeof current !== "object") return current;
|
||||
|
||||
if (seen.has(current)) return `[Circular ${marker}]`;
|
||||
if (depth >= JSON_MAX_DEPTH) {
|
||||
return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`;
|
||||
}
|
||||
|
||||
seen.add(current);
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const result: unknown[] = [];
|
||||
const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY);
|
||||
for (let i = 0; i < maxItems; i += 1) {
|
||||
const remaining = current.length - i;
|
||||
result.push(visit(current[i], depth + 1));
|
||||
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
|
||||
result.pop();
|
||||
result.push(`${marker}: ${remaining} more items`);
|
||||
seen.delete(current);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (current.length > maxItems) {
|
||||
result.push(`${marker}: ${current.length - maxItems} more items`);
|
||||
}
|
||||
seen.delete(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
const entries = Object.entries(current as Record<string, unknown>);
|
||||
const result: Record<string, unknown> = {};
|
||||
const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT);
|
||||
for (let i = 0; i < maxKeys; i += 1) {
|
||||
const [key, val] = entries[i];
|
||||
const remaining = entries.length - i;
|
||||
result[key] = visit(val, depth + 1);
|
||||
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
|
||||
delete result[key];
|
||||
result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`;
|
||||
seen.delete(current);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (entries.length > maxKeys) {
|
||||
result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`;
|
||||
}
|
||||
seen.delete(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
const truncated = visit(value, 0);
|
||||
if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated;
|
||||
return { [JSON_TRUNCATED_KEY]: marker };
|
||||
}
|
||||
|
||||
function formatToolPayload(raw?: string): ToolPayload {
|
||||
if (!raw) {
|
||||
return { full: "", display: "" };
|
||||
}
|
||||
|
||||
try {
|
||||
const full = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
const parsed = JSON.parse(raw);
|
||||
const full = JSON.stringify(parsed, null, 2);
|
||||
const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
||||
? JSON.stringify(truncateJsonValue(parsed, t("chat.truncated")), null, 2)
|
||||
: full;
|
||||
return {
|
||||
full,
|
||||
display:
|
||||
full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
||||
? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated")
|
||||
: full,
|
||||
display,
|
||||
language: "json",
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -6,10 +6,12 @@ import { useChatStore } from "@/stores/hermes/chat";
|
||||
import thinkingVideoLight from "@/assets/thinking-light.mp4";
|
||||
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const listRef = ref<HTMLElement>();
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
@@ -41,9 +43,16 @@ const currentToolCalls = computed(() => {
|
||||
return [...tools].reverse();
|
||||
});
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter((m) => {
|
||||
if (m.role === "tool") return false;
|
||||
const visibleToolCalls = computed(() =>
|
||||
toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [],
|
||||
);
|
||||
|
||||
const displayMessages = computed(() => {
|
||||
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
|
||||
return chatStore.messages.filter((m) => {
|
||||
if (m.role === "tool") {
|
||||
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
|
||||
}
|
||||
if (
|
||||
m.role === "assistant" &&
|
||||
m.isStreaming &&
|
||||
@@ -54,8 +63,8 @@ const displayMessages = computed(() =>
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const queuedMessages = computed(() => {
|
||||
const sid = chatStore.activeSessionId;
|
||||
@@ -171,7 +180,7 @@ watch(currentToolCalls, () => {
|
||||
playsinline
|
||||
class="thinking-video"
|
||||
/>
|
||||
<div v-if="currentToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
|
||||
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
|
||||
<!-- Abort indicator -->
|
||||
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
|
||||
<svg
|
||||
@@ -254,7 +263,7 @@ watch(currentToolCalls, () => {
|
||||
</div>
|
||||
<!-- Tool calls -->
|
||||
<div
|
||||
v-for="tc in currentToolCalls"
|
||||
v-for="tc in visibleToolCalls"
|
||||
:key="tc.id"
|
||||
class="tool-call-item"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user