mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): add chat detail visibility selector (#2886)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -64,7 +64,13 @@
|
||||
"toolCallExplanationLabel": "调用提示",
|
||||
"toolCallFunctionLabel": "调用摘要",
|
||||
"toolCallArgumentsLabel": "参数",
|
||||
"showAssistantDetails": "展示思考过程与工具调用",
|
||||
"showAssistantDetails": "思考与工具调用",
|
||||
"assistantDetailVisibility": {
|
||||
"none": "均不展示",
|
||||
"thought": "仅展示思考消息",
|
||||
"toolCalls": "仅展示工具调用",
|
||||
"all": "均展示"
|
||||
},
|
||||
"toolLabel": "工具",
|
||||
"codeLabel": "代码",
|
||||
"copyMessage": "复制消息",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user