Commit 68462773 authored by edy's avatar edy

fix(ui): persist hidden chat messages

parent c3f99f53
Pipeline #18460 failed
......@@ -25,6 +25,8 @@ Use Corepack so the workspace stays on the pinned `pnpm` version.
## macOS Packaging Notes
Current unsigned and unnotarized handoff builds should be packaged as the larger uncompressed DMG first. The reason is: 因为现在没有公证和签名,先打包成比较大的 dmg. Use `compression: store` / `dmg.format=UDRO` for that temporary handoff path.
DMG artifact names should use the timestamp format `千匠问天-YYYYMMDDHHmm.dmg`, for example `千匠问天-202605141337.dmg`.
After signing and notarization are ready, customer releases should be distributed as a compressed `.dmg`, not as a zipped DMG and not as an uncompressed DMG. The desktop electron-builder config normally keeps `compression: maximum`; using `compression: store` produces an uncompressed APFS DMG around 1.95 GB for the bundled runtime payload.
Run `corepack pnpm run package:mac` first so the runtime is materialized and `dist/installer/mac-arm64/千匠问天.app` is rebuilt from the latest code. If electron-builder finishes the `.app` but fails while creating the DMG with `hdiutil create`, do not ship a quick `UDZO` fallback. Create a bzip2-compressed DMG and then rewrite it with `hdiutil convert` so wasted image bytes are removed:
......
......@@ -98,6 +98,7 @@ import {
SUCCESS_NOTICE_TIMEOUT_MS
} from "./lib/constants";
import { desktopApi, isMockDesktopApi, smokeEnabled } from "./lib/desktop-api";
import { getHiddenMessageIds, persistHiddenMessageId } from "./lib/hiddenMessages";
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
......@@ -714,11 +715,13 @@ export default function App() {
setLoadingHistorySessionId(sessionId);
try {
const hiddenMessageIds = getHiddenMessageIds(sessionId);
const nextMessages = (await desktopApi.chat.listMessages(sessionId))
.filter(isPrimaryChatMessage)
.filter((message) => !hiddenMessageIds.has(message.id))
.map((message) => toUiChatMessage(message));
updateSessionMessages(sessionId, (current) => {
return mergeSessionHistory(current, nextMessages);
return mergeSessionHistory(current.filter((message) => !hiddenMessageIds.has(message.id)), nextMessages);
});
loadedHistorySessionIdsRef.current.add(sessionId);
} catch (error) {
......@@ -919,13 +922,17 @@ export default function App() {
const nextEntries = await Promise.all(scopedSessions.map(async (session, index) => {
const cachedMessages = messagesBySession[session.id];
const hiddenMessageIds = getHiddenMessageIds(session.id);
if (cachedMessages?.length) {
return [session.id, deriveSidebarSessionTitle(toPlainMessages(cachedMessages))] as const;
const visibleCachedMessages = cachedMessages.filter((message) => !hiddenMessageIds.has(message.id));
return [session.id, deriveSidebarSessionTitle(toPlainMessages(visibleCachedMessages))] as const;
}
try {
const rawMessages = await desktopApi.chat.listMessages(session.id);
const visibleMessages = rawMessages.filter(isPrimaryChatMessage);
const visibleMessages = rawMessages
.filter(isPrimaryChatMessage)
.filter((message) => !hiddenMessageIds.has(message.id));
return [session.id, deriveSidebarSessionTitle(visibleMessages)] as const;
} catch {
return [session.id, formatSessionTitle(session.title, index)] as const;
......@@ -1031,6 +1038,7 @@ export default function App() {
return;
}
persistHiddenMessageId(visibleSessionId, messageId);
updateSessionMessages(visibleSessionId, (current) => {
const nextMessages = current.filter((message) => message.id !== messageId);
return nextMessages.length === current.length ? current : nextMessages;
......
......@@ -8,7 +8,10 @@ import {
clampComposerTextareaHeight,
getComposerTextareaBounds
} from "../../lib/chat-utils";
import { COMPOSER_TEXTAREA_DEFAULT_RATIO } from "../../lib/constants";
import {
COMPOSER_TEXTAREA_DEFAULT_MIN_HEIGHT,
COMPOSER_TEXTAREA_DEFAULT_RATIO
} from "../../lib/constants";
interface UseComposerResizeOptions {
isConversationView: boolean;
......@@ -18,9 +21,10 @@ interface UseComposerResizeOptions {
export function useComposerResize({ isConversationView, workspaceRef }: UseComposerResizeOptions) {
const [isComposerResizeActive, setIsComposerResizeActive] = useState(false);
const [composerTextareaRatio, setComposerTextareaRatio] = useState(COMPOSER_TEXTAREA_DEFAULT_RATIO);
const [composerTextareaHeight, setComposerTextareaHeight] = useState(77);
const [composerTextareaHeight, setComposerTextareaHeight] = useState(COMPOSER_TEXTAREA_DEFAULT_MIN_HEIGHT);
const [composerWorkspaceHeight, setComposerWorkspaceHeight] = useState(0);
const composerResizeDragRef = useRef<{ startY: number; startHeight: number; workspaceHeight: number } | null>(null);
const hasComposerResizePreferenceRef = useRef(false);
const composerTextareaBounds = useMemo(() => getComposerTextareaBounds(composerWorkspaceHeight), [composerWorkspaceHeight]);
useEffect(() => {
......@@ -36,7 +40,10 @@ export function useComposerResize({ isConversationView, workspaceRef }: UseCompo
const updateComposerHeight = (workspaceHeight: number) => {
const safeWorkspaceHeight = Number.isFinite(workspaceHeight) && workspaceHeight > 0 ? workspaceHeight : 0;
setComposerWorkspaceHeight((currentHeight) => Math.abs(currentHeight - safeWorkspaceHeight) < 0.5 ? currentHeight : safeWorkspaceHeight);
const nextHeight = clampComposerTextareaHeight(safeWorkspaceHeight * composerTextareaRatio, safeWorkspaceHeight);
const bounds = getComposerTextareaBounds(safeWorkspaceHeight);
const nextHeight = hasComposerResizePreferenceRef.current
? clampComposerTextareaHeight(safeWorkspaceHeight * composerTextareaRatio, safeWorkspaceHeight)
: bounds.min;
setComposerTextareaHeight((currentHeight) => Math.abs(currentHeight - nextHeight) < 0.5 ? currentHeight : nextHeight);
};
......@@ -82,6 +89,7 @@ export function useComposerResize({ isConversationView, workspaceRef }: UseCompo
setComposerWorkspaceHeight((currentHeight) => Math.abs(currentHeight - workspaceHeight) < 0.5 ? currentHeight : workspaceHeight);
setComposerTextareaHeight(nextHeight);
setComposerTextareaRatio(nextHeight / Math.max(workspaceHeight, 1));
hasComposerResizePreferenceRef.current = true;
event.preventDefault();
}
......
export const HIDDEN_MESSAGE_IDS_STORAGE_KEY = "qjclaw:hidden-message-ids:v1"
type HiddenMessageIdsBySession = Record<string, string[]>
type HiddenMessageStorage = Pick<Storage, "getItem" | "setItem">
function getDefaultStorage(): HiddenMessageStorage | undefined {
try {
return globalThis.localStorage
} catch {
return undefined
}
}
function readHiddenMessageIds(storage = getDefaultStorage()): HiddenMessageIdsBySession {
if (!storage) {
return {}
}
try {
const rawValue = storage.getItem(HIDDEN_MESSAGE_IDS_STORAGE_KEY)
if (!rawValue) {
return {}
}
const parsedValue = JSON.parse(rawValue) as unknown
if (!parsedValue || typeof parsedValue !== "object" || Array.isArray(parsedValue)) {
return {}
}
return Object.fromEntries(Object.entries(parsedValue).flatMap(([sessionId, messageIds]) => {
if (!Array.isArray(messageIds)) {
return []
}
const validMessageIds = messageIds.filter((messageId): messageId is string => typeof messageId === "string")
return validMessageIds.length > 0 ? [[sessionId, [...new Set(validMessageIds)]]] : []
}))
} catch {
return {}
}
}
export function getHiddenMessageIds(sessionId: string, storage?: HiddenMessageStorage): Set<string> {
return new Set(readHiddenMessageIds(storage)[sessionId] ?? [])
}
export function persistHiddenMessageId(sessionId: string, messageId: string, storage = getDefaultStorage()): void {
if (!storage || !sessionId || !messageId) {
return
}
const hiddenMessageIds = readHiddenMessageIds(storage)
const sessionHiddenIds = hiddenMessageIds[sessionId] ?? []
if (sessionHiddenIds.includes(messageId)) {
return
}
try {
storage.setItem(HIDDEN_MESSAGE_IDS_STORAGE_KEY, JSON.stringify({
...hiddenMessageIds,
[sessionId]: [...sessionHiddenIds, messageId]
}))
} catch {
// Current in-memory deletion still succeeds when persistence is unavailable.
}
}
......@@ -1135,6 +1135,7 @@
margin-top: auto;
background: linear-gradient(180deg, rgba(248, 251, 255, 0), rgba(248, 251, 255, 0.92) 24px);
box-shadow: none;
border: 0;
border-top: 1px solid rgba(147, 197, 253, 0.22);
}
......
import test from "node:test"
import assert from "node:assert/strict"
import {
HIDDEN_MESSAGE_IDS_STORAGE_KEY,
getHiddenMessageIds,
persistHiddenMessageId
} from "../src/lib/hiddenMessages.ts"
function createMemoryStorage(initialValue?: string) {
const values = new Map<string, string>()
if (initialValue !== undefined) {
values.set(HIDDEN_MESSAGE_IDS_STORAGE_KEY, initialValue)
}
return {
getItem(key: string) {
return values.get(key) ?? null
},
setItem(key: string, value: string) {
values.set(key, value)
}
}
}
test("persists hidden message ids by session", () => {
const storage = createMemoryStorage()
persistHiddenMessageId("session-a", "message-1", storage)
persistHiddenMessageId("session-a", "message-2", storage)
persistHiddenMessageId("session-b", "message-3", storage)
assert.deepEqual([...getHiddenMessageIds("session-a", storage)], ["message-1", "message-2"])
assert.deepEqual([...getHiddenMessageIds("session-b", storage)], ["message-3"])
})
test("deduplicates hidden message ids and ignores invalid storage", () => {
const invalidStorage = createMemoryStorage("{")
assert.deepEqual([...getHiddenMessageIds("session-a", invalidStorage)], [])
const storage = createMemoryStorage()
persistHiddenMessageId("session-a", "message-1", storage)
persistHiddenMessageId("session-a", "message-1", storage)
assert.deepEqual([...getHiddenMessageIds("session-a", storage)], ["message-1"])
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment