Files
penguin-harness/packages/web/src/features/chat/draft-cache.ts
T
2026-07-29 23:37:37 +08:00

135 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Draft cache (localStorage; one entry per "user × Project" for new conversations, one per
* "user × Session" for existing sessions): reads validate field-by-field — storage may have been
* corrupted externally, so bad fields are dropped rather than crashing the page; writes are
* best-effort (silently fail under quota limits/private browsing). Pure functions + injectable
* storage: vitest runs in a Node environment (no localStorage), so unit tests inject an in-memory
* implementation.
*
* The key must include userId (#68): if the same browser logs into different accounts in
* succession and the key only contains the Project/Session ID, the later user would recover the
* previous user's text, Workspace, model selection, and handoff target — a cross-account information leak.
*/
import type { ApprovalMode } from "@prismshadow/penguin-server/api";
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
export interface DraftCache {
text?: string;
agentId?: string;
workspace?: string;
approvalMode?: ApprovalMode;
/**
* The model selected in the draft (a paired reference; (provider, modelId) is the unique key):
* load validates the object shape; the old string-typed modelId field is simply dropped
* (product hasn't shipped, so no migration is done).
*/
modelRef?: { provider: string; modelId: string };
/** The `/agent` handoff target (chip) at the front of the input box: resolved again by id on restore, dropped if no longer valid. */
handoffAgentId?: string;
/**
* The `/model` switch target (the other switch chip; a paired reference, same shape as
* modelRef above): cached for exactly the same reason as handoffAgentId — the composer text
* is cached and ChatInput remounts on every session switch, so a chip left in component state
* would vanish while the text it belongs to came back, and the next Enter would post to the
* current session on the old model. Resolved again against the model list on restore and
* dropped when that model is no longer available.
*/
switchModelRef?: { provider: string; modelId: string };
/**
* Preselected skill names (written by the quick-invoke action on the Skill library page):
* used as the initial selection when ChatInput mounts, then trimmed to remove names not in the
* installed list once it's ready; cleared along with the entire draft on successful send.
*/
skills?: string[];
}
/** Minimal storage interface (a subset of localStorage). */
export interface DraftStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Cache key for a new conversation (draft page): one per "user × Project". */
export const draftKey = (userId: string, projectId: string): string =>
`penguin.chatDraft.${userId}.${projectId}`;
/** Cache key for an existing session's input area: one per "user × Session" (only stores text and the handoff target; everything else is locked to the Session). */
export const sessionDraftKey = (userId: string, sessionId: string): string =>
`penguin.chatDraft.session.${userId}.${sessionId}`;
/**
* A cached model reference must be a paired `{ provider, modelId }` object; anything else — the
* old string-typed modelId, a half reference, a non-object — yields undefined and the field is
* dropped. Shared by the two model fields (the draft's selection and the staged `/model` switch).
*/
function parseModelRef(value: unknown): { provider: string; modelId: string } | undefined {
if (typeof value !== "object" || value === null) return undefined;
const r = value as Record<string, unknown>;
if (typeof r.provider !== "string" || typeof r.modelId !== "string") return undefined;
return { provider: r.provider, modelId: r.modelId };
}
/** Parses and validates raw JSON field-by-field: null / malformed JSON / non-object / invalid fields are all dropped. */
export function parseDraft(raw: string | null): DraftCache {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const o = parsed as Record<string, unknown>;
const out: DraftCache = {};
if (typeof o.text === "string") out.text = o.text;
if (typeof o.agentId === "string") out.agentId = o.agentId;
if (typeof o.workspace === "string") out.workspace = o.workspace;
const modelRef = parseModelRef(o.modelRef);
if (modelRef) out.modelRef = modelRef;
if (typeof o.handoffAgentId === "string") out.handoffAgentId = o.handoffAgentId;
const switchModelRef = parseModelRef(o.switchModelRef);
if (switchModelRef) out.switchModelRef = switchModelRef;
if (Array.isArray(o.skills)) {
// Elements are validated one by one: non-string items are filtered out; if empty after
// filtering, the whole field is omitted.
const skills = o.skills.filter((s): s is string => typeof s === "string");
if (skills.length > 0) out.skills = skills;
}
if (
typeof o.approvalMode === "string" &&
APPROVAL_MODES.includes(o.approvalMode as ApprovalMode)
) {
out.approvalMode = o.approvalMode as ApprovalMode;
}
return out;
} catch {
return {};
}
}
export function loadDraft(key: string, storage: DraftStorage = localStorage): DraftCache {
try {
return parseDraft(storage.getItem(key));
} catch {
return {};
}
}
export function saveDraft(
key: string,
draft: DraftCache,
storage: DraftStorage = localStorage,
): void {
try {
storage.setItem(key, JSON.stringify(draft));
} catch {
/* Write fails under quota limits/private browsing: draft cache is best-effort */
}
}
export function clearDraft(key: string, storage: DraftStorage = localStorage): void {
try {
storage.removeItem(key);
} catch {
/* ignore */
}
}