feat(web): add chat detail visibility selector (#2886)
This commit is contained in:
parent
cb5d33124c
commit
941bac2332
6 changed files with 344 additions and 21 deletions
|
|
@ -16,14 +16,24 @@ import { TypingIndicator } from "@/components/chat/typing-indicator"
|
||||||
import { UserMessage } from "@/components/chat/user-message"
|
import { UserMessage } from "@/components/chat/user-message"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useChatModels } from "@/hooks/use-chat-models"
|
||||||
import { useGateway } from "@/hooks/use-gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||||
|
import type { AssistantDetailVisibility } from "@/store/chat"
|
||||||
import type { ConnectionState } from "@/store/chat"
|
import type { ConnectionState } from "@/store/chat"
|
||||||
import type { ChatAttachment } 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"
|
import type { GatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||||
|
|
@ -112,10 +122,23 @@ export function ChatPage() {
|
||||||
const [hasScrolled, setHasScrolled] = useState(false)
|
const [hasScrolled, setHasScrolled] = useState(false)
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
const [showAssistantDetails, setShowAssistantDetails] = useAtom(
|
const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom(
|
||||||
showAssistantDetailsAtom,
|
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 {
|
const {
|
||||||
messages,
|
messages,
|
||||||
connectionState,
|
connectionState,
|
||||||
|
|
@ -275,12 +298,27 @@ export function ChatPage() {
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
{t("chat.showAssistantDetails")}
|
{t("chat.showAssistantDetails")}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Select
|
||||||
checked={showAssistantDetails}
|
value={assistantDetailVisibility}
|
||||||
onCheckedChange={setShowAssistantDetails}
|
onValueChange={(value) =>
|
||||||
aria-label={t("chat.showAssistantDetails")}
|
setAssistantDetailVisibility(value as AssistantDetailVisibility)
|
||||||
size="sm"
|
}
|
||||||
/>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -326,8 +364,7 @@ export function ChatPage() {
|
||||||
|
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
if (
|
if (
|
||||||
!showAssistantDetails &&
|
!shouldShowAssistantMessage(assistantDetailVisibility, msg.kind)
|
||||||
(msg.kind === "thought" || msg.kind === "tool_calls")
|
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
255
web/frontend/src/features/chat/detail-visibility.ts
Normal file
255
web/frontend/src/features/chat/detail-visibility.ts
Normal file
|
|
@ -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",
|
"toolCallExplanationLabel": "Call note",
|
||||||
"toolCallFunctionLabel": "Call summary",
|
"toolCallFunctionLabel": "Call summary",
|
||||||
"toolCallArgumentsLabel": "Arguments",
|
"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",
|
"toolLabel": "Tool",
|
||||||
"codeLabel": "Code",
|
"codeLabel": "Code",
|
||||||
"copyMessage": "Copy message",
|
"copyMessage": "Copy message",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,13 @@
|
||||||
"toolCallExplanationLabel": "Nota da chamada",
|
"toolCallExplanationLabel": "Nota da chamada",
|
||||||
"toolCallFunctionLabel": "Resumo da chamada",
|
"toolCallFunctionLabel": "Resumo da chamada",
|
||||||
"toolCallArgumentsLabel": "Argumentos",
|
"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",
|
"toolLabel": "Ferramenta",
|
||||||
"codeLabel": "Código",
|
"codeLabel": "Código",
|
||||||
"copyMessage": "Copiar mensagem",
|
"copyMessage": "Copiar mensagem",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,13 @@
|
||||||
"toolCallExplanationLabel": "调用提示",
|
"toolCallExplanationLabel": "调用提示",
|
||||||
"toolCallFunctionLabel": "调用摘要",
|
"toolCallFunctionLabel": "调用摘要",
|
||||||
"toolCallArgumentsLabel": "参数",
|
"toolCallArgumentsLabel": "参数",
|
||||||
"showAssistantDetails": "展示思考过程与工具调用",
|
"showAssistantDetails": "思考与工具调用",
|
||||||
|
"assistantDetailVisibility": {
|
||||||
|
"none": "均不展示",
|
||||||
|
"thought": "仅展示思考消息",
|
||||||
|
"toolCalls": "仅展示工具调用",
|
||||||
|
"all": "均展示"
|
||||||
|
},
|
||||||
"toolLabel": "工具",
|
"toolLabel": "工具",
|
||||||
"codeLabel": "代码",
|
"codeLabel": "代码",
|
||||||
"copyMessage": "复制消息",
|
"copyMessage": "复制消息",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { atom, getDefaultStore } from "jotai"
|
import { atom, getDefaultStore } from "jotai"
|
||||||
import { atomWithStorage } from "jotai/utils"
|
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 {
|
import {
|
||||||
getInitialActiveSessionId,
|
getInitialActiveSessionId,
|
||||||
writeStoredSessionId,
|
writeStoredSessionId,
|
||||||
|
|
@ -65,9 +72,6 @@ export interface ChatStoreState {
|
||||||
|
|
||||||
type ChatStorePatch = Partial<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 = {
|
const DEFAULT_CHAT_STATE: ChatStoreState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
connectionState: "disconnected",
|
connectionState: "disconnected",
|
||||||
|
|
@ -77,9 +81,15 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
|
||||||
export const showAssistantDetailsAtom = atomWithStorage<boolean>(
|
export const assistantDetailVisibilityAtom =
|
||||||
SHOW_ASSISTANT_DETAILS_STORAGE_KEY,
|
atomWithStorage<AssistantDetailVisibility>(
|
||||||
true,
|
ASSISTANT_DETAIL_VISIBILITY_STORAGE_KEY,
|
||||||
|
DEFAULT_ASSISTANT_DETAIL_VISIBILITY,
|
||||||
|
assistantDetailVisibilityStorage,
|
||||||
|
{ getOnInit: true },
|
||||||
|
)
|
||||||
|
export const showAssistantDetailsAtom = atom(
|
||||||
|
(get) => get(assistantDetailVisibilityAtom) !== "none",
|
||||||
)
|
)
|
||||||
|
|
||||||
const store = getDefaultStore()
|
const store = getDefaultStore()
|
||||||
|
|
@ -104,3 +114,6 @@ export function updateChatStore(
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { shouldShowAssistantMessage, DEFAULT_ASSISTANT_DETAIL_VISIBILITY }
|
||||||
|
export type { AssistantDetailVisibility }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue