feat(web): add chat detail visibility selector (#2886)

This commit is contained in:
LC
2026-05-18 14:50:57 +08:00
committed by GitHub
parent cb5d33124c
commit 941bac2332
6 changed files with 344 additions and 21 deletions
+49 -12
View File
@@ -16,14 +16,24 @@ import { TypingIndicator } from "@/components/chat/typing-indicator"
import { UserMessage } from "@/components/chat/user-message"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history"
import type { AssistantDetailVisibility } from "@/store/chat"
import type { ConnectionState } from "@/store/chat"
import type { ChatAttachment } from "@/store/chat"
import { showAssistantDetailsAtom } from "@/store/chat"
import {
assistantDetailVisibilityAtom,
shouldShowAssistantMessage,
} from "@/store/chat"
import type { GatewayState } from "@/store/gateway"
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
@@ -112,10 +122,23 @@ export function ChatPage() {
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [showAssistantDetails, setShowAssistantDetails] = useAtom(
showAssistantDetailsAtom,
const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom(
assistantDetailVisibilityAtom,
)
const assistantDetailVisibilityOptions: Array<{
value: AssistantDetailVisibility
label: string
}> = [
{ value: "none", label: t("chat.assistantDetailVisibility.none") },
{ value: "thought", label: t("chat.assistantDetailVisibility.thought") },
{
value: "tool_calls",
label: t("chat.assistantDetailVisibility.toolCalls"),
},
{ value: "all", label: t("chat.assistantDetailVisibility.all") },
]
const {
messages,
connectionState,
@@ -275,12 +298,27 @@ export function ChatPage() {
<span className="text-muted-foreground text-sm">
{t("chat.showAssistantDetails")}
</span>
<Switch
checked={showAssistantDetails}
onCheckedChange={setShowAssistantDetails}
aria-label={t("chat.showAssistantDetails")}
size="sm"
/>
<Select
value={assistantDetailVisibility}
onValueChange={(value) =>
setAssistantDetailVisibility(value as AssistantDetailVisibility)
}
>
<SelectTrigger
size="sm"
aria-label={t("chat.showAssistantDetails")}
className="text-muted-foreground hover:text-foreground focus-visible:border-input h-8 min-w-[104px] bg-transparent shadow-none focus-visible:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{assistantDetailVisibilityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
@@ -326,8 +364,7 @@ export function ChatPage() {
{messages.map((msg) => {
if (
!showAssistantDetails &&
(msg.kind === "thought" || msg.kind === "tool_calls")
!shouldShowAssistantMessage(assistantDetailVisibility, msg.kind)
) {
return null
}
@@ -0,0 +1,255 @@
export type AssistantDetailVisibility =
| "none"
| "thought"
| "tool_calls"
| "all"
export type AssistantDetailMessageKind =
| "normal"
| "thought"
| "tool_calls"
| undefined
interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
interface AssistantDetailVisibilityDecision {
value: AssistantDetailVisibility
newValueAction: "keep" | "write" | "remove"
removeLegacyValue: boolean
}
export const ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY =
"picoclaw:chat-assistant-detail-visibility"
export const LEGACY_SHOW_ASSISTANT_DETAILS_STORAGE_KEY =
"picoclaw:chat-show-thoughts"
export const DEFAULT_ASSISTANT_DETAIL_VISIBILITY: AssistantDetailVisibility =
"all"
function getSafeLocalStorage(): StorageLike | undefined {
try {
return globalThis.localStorage
} catch {
return undefined
}
}
function serializeAssistantDetailVisibility(
value: AssistantDetailVisibility,
): string {
return JSON.stringify(value)
}
function parseStoredValue(rawValue: string | null): unknown {
if (rawValue === null) {
return undefined
}
try {
return JSON.parse(rawValue)
} catch {
return rawValue.trim()
}
}
function parseAssistantDetailVisibility(
rawValue: unknown,
): AssistantDetailVisibility | undefined {
if (typeof rawValue !== "string") {
return undefined
}
const normalized = rawValue.trim().toLowerCase()
if (
normalized === "none" ||
normalized === "thought" ||
normalized === "tool_calls" ||
normalized === "all"
) {
return normalized
}
return undefined
}
function parseLegacyShowAssistantDetails(
rawValue: unknown,
): boolean | undefined {
if (typeof rawValue === "boolean") {
return rawValue
}
if (typeof rawValue !== "string") {
return undefined
}
const normalized = rawValue.trim().toLowerCase()
if (normalized === "true") {
return true
}
if (normalized === "false") {
return false
}
return undefined
}
export function resolveAssistantDetailVisibilityPreference(
storedValue: string | null,
legacyStoredValue: string | null,
): AssistantDetailVisibilityDecision {
const nextValue = parseAssistantDetailVisibility(
parseStoredValue(storedValue),
)
if (nextValue) {
return {
value: nextValue,
newValueAction:
storedValue === serializeAssistantDetailVisibility(nextValue)
? "keep"
: "write",
removeLegacyValue: legacyStoredValue !== null,
}
}
const legacyValue = parseLegacyShowAssistantDetails(
parseStoredValue(legacyStoredValue),
)
if (legacyValue !== undefined) {
return {
value: legacyValue ? "all" : "none",
newValueAction: "write",
removeLegacyValue: legacyStoredValue !== null,
}
}
return {
value: DEFAULT_ASSISTANT_DETAIL_VISIBILITY,
newValueAction: storedValue !== null ? "remove" : "keep",
removeLegacyValue: legacyStoredValue !== null,
}
}
export function syncAssistantDetailVisibilityStorage(
storage?: StorageLike,
): AssistantDetailVisibility {
const resolvedStorage = storage ?? getSafeLocalStorage()
if (!resolvedStorage) {
return DEFAULT_ASSISTANT_DETAIL_VISIBILITY
}
let decision: AssistantDetailVisibilityDecision
try {
decision = resolveAssistantDetailVisibilityPreference(
resolvedStorage.getItem(ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY),
resolvedStorage.getItem(LEGACY_SHOW_ASSISTANT_DETAILS_STORAGE_KEY),
)
} catch {
return DEFAULT_ASSISTANT_DETAIL_VISIBILITY
}
if (decision.newValueAction === "write") {
try {
resolvedStorage.setItem(
ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY,
serializeAssistantDetailVisibility(decision.value),
)
} catch {
// Ignore migration write failures and keep the parsed preference value.
}
} else if (decision.newValueAction === "remove") {
try {
resolvedStorage.removeItem(ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY)
} catch {
// Ignore cleanup failures and keep the parsed preference value.
}
}
if (decision.removeLegacyValue) {
try {
resolvedStorage.removeItem(LEGACY_SHOW_ASSISTANT_DETAILS_STORAGE_KEY)
} catch {
// Ignore cleanup failures and keep the parsed preference value.
}
}
return decision.value
}
export const assistantDetailVisibilityStorage = {
getItem(): AssistantDetailVisibility {
return syncAssistantDetailVisibilityStorage()
},
setItem(key: string, newValue: AssistantDetailVisibility) {
const storage = getSafeLocalStorage()
if (!storage) {
return
}
try {
storage.setItem(key, serializeAssistantDetailVisibility(newValue))
storage.removeItem(LEGACY_SHOW_ASSISTANT_DETAILS_STORAGE_KEY)
} catch {
// Ignore storage write failures and keep the in-memory atom state.
}
},
removeItem(key: string) {
const storage = getSafeLocalStorage()
if (!storage) {
return
}
try {
storage.removeItem(key)
} catch {
// Ignore storage write failures and keep the in-memory atom state.
}
},
subscribe(key: string, callback: (value: AssistantDetailVisibility) => void) {
if (
typeof window === "undefined" ||
typeof window.addEventListener !== "function"
) {
return undefined
}
const handleStorage = (event: StorageEvent) => {
const storage = getSafeLocalStorage()
if (
!storage ||
event.storageArea !== storage ||
(event.key !== key &&
event.key !== LEGACY_SHOW_ASSISTANT_DETAILS_STORAGE_KEY)
) {
return
}
callback(syncAssistantDetailVisibilityStorage(storage))
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
},
}
export function shouldShowAssistantMessage(
visibility: AssistantDetailVisibility,
kind: AssistantDetailMessageKind,
): boolean {
if (kind !== "thought" && kind !== "tool_calls") {
return true
}
if (visibility === "all") {
return true
}
if (visibility === "none") {
return false
}
return visibility === kind
}
+7 -1
View File
@@ -64,7 +64,13 @@
"toolCallExplanationLabel": "Call note",
"toolCallFunctionLabel": "Call summary",
"toolCallArgumentsLabel": "Arguments",
"showAssistantDetails": "Show reasoning and tool calls",
"showAssistantDetails": "Reasoning and tool calls",
"assistantDetailVisibility": {
"none": "Show neither",
"thought": "Show reasoning only",
"toolCalls": "Show tool calls only",
"all": "Show both"
},
"toolLabel": "Tool",
"codeLabel": "Code",
"copyMessage": "Copy message",
+7 -1
View File
@@ -64,7 +64,13 @@
"toolCallExplanationLabel": "Nota da chamada",
"toolCallFunctionLabel": "Resumo da chamada",
"toolCallArgumentsLabel": "Argumentos",
"showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas",
"showAssistantDetails": "Raciocínio e chamadas de ferramentas",
"assistantDetailVisibility": {
"none": "Não mostrar nenhum",
"thought": "Mostrar apenas raciocínio",
"toolCalls": "Mostrar apenas chamadas de ferramentas",
"all": "Mostrar ambos"
},
"toolLabel": "Ferramenta",
"codeLabel": "Código",
"copyMessage": "Copiar mensagem",
+7 -1
View File
@@ -64,7 +64,13 @@
"toolCallExplanationLabel": "调用提示",
"toolCallFunctionLabel": "调用摘要",
"toolCallArgumentsLabel": "参数",
"showAssistantDetails": "展示思考过程与工具调用",
"showAssistantDetails": "思考与工具调用",
"assistantDetailVisibility": {
"none": "均不展示",
"thought": "仅展示思考消息",
"toolCalls": "仅展示工具调用",
"all": "均展示"
},
"toolLabel": "工具",
"codeLabel": "代码",
"copyMessage": "复制消息",
+19 -6
View File
@@ -1,6 +1,13 @@
import { atom, getDefaultStore } from "jotai"
import { atomWithStorage } from "jotai/utils"
import {
ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY,
type AssistantDetailVisibility,
DEFAULT_ASSISTANT_DETAIL_VISIBILITY,
assistantDetailVisibilityStorage,
shouldShowAssistantMessage,
} from "@/features/chat/detail-visibility"
import {
getInitialActiveSessionId,
writeStoredSessionId,
@@ -65,9 +72,6 @@ export interface ChatStoreState {
type ChatStorePatch = Partial<ChatStoreState>
// Keep the legacy storage value so existing user preferences survive the rename.
const SHOW_ASSISTANT_DETAILS_STORAGE_KEY = "picoclaw:chat-show-thoughts"
const DEFAULT_CHAT_STATE: ChatStoreState = {
messages: [],
connectionState: "disconnected",
@@ -77,9 +81,15 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
}
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
export const showAssistantDetailsAtom = atomWithStorage<boolean>(
SHOW_ASSISTANT_DETAILS_STORAGE_KEY,
true,
export const assistantDetailVisibilityAtom =
atomWithStorage<AssistantDetailVisibility>(
ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY,
DEFAULT_ASSISTANT_DETAIL_VISIBILITY,
assistantDetailVisibilityStorage,
{ getOnInit: true },
)
export const showAssistantDetailsAtom = atom(
(get) => get(assistantDetailVisibilityAtom) !== "none",
)
const store = getDefaultStore()
@@ -104,3 +114,6 @@ export function updateChatStore(
return next
})
}
export { shouldShowAssistantMessage, DEFAULT_ASSISTANT_DETAIL_VISIBILITY }
export type { AssistantDetailVisibility }