Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements
This commit is contained in:
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal file
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it, mock } from "node:test"
|
||||
import type { CompactionResult } from "../session-compaction.js"
|
||||
import {
|
||||
getCompactionConfig,
|
||||
updateCompactionConfig,
|
||||
undoCompaction,
|
||||
rehydrateSession,
|
||||
checkAndTriggerAutoCompact,
|
||||
setSessionCompactionState,
|
||||
getSessionCompactionState,
|
||||
estimateTokenReduction,
|
||||
executeCompactionWrapper,
|
||||
} from "../session-compaction.js"
|
||||
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
|
||||
|
||||
const MOCK_INSTANCE_ID = "test-instance-123"
|
||||
const MOCK_SESSION_ID = "test-session-456"
|
||||
const MOCK_MESSAGE_ID = "msg-789"
|
||||
|
||||
function createMockMessage(id: string, content: string = "Test message"): any {
|
||||
return {
|
||||
id,
|
||||
sessionId: MOCK_SESSION_ID,
|
||||
role: "user",
|
||||
content,
|
||||
status: "complete",
|
||||
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockUsage(tokens: number = 10000): any {
|
||||
return {
|
||||
totalInputTokens: Math.floor(tokens * 0.7),
|
||||
totalOutputTokens: Math.floor(tokens * 0.2),
|
||||
totalReasoningTokens: Math.floor(tokens * 0.1),
|
||||
}
|
||||
}
|
||||
|
||||
describe("session compaction", () => {
|
||||
beforeEach(() => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "auto",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCompactionConfig", () => {
|
||||
it("returns default config", () => {
|
||||
const config = getCompactionConfig()
|
||||
assert.equal(typeof config.autoCompactEnabled, "boolean")
|
||||
assert.equal(typeof config.autoCompactThreshold, "number")
|
||||
assert.equal(typeof config.compactPreserveWindow, "number")
|
||||
assert.equal(typeof config.pruneReclaimThreshold, "number")
|
||||
assert.equal(typeof config.userPreference, "string")
|
||||
assert.equal(typeof config.undoRetentionWindow, "number")
|
||||
})
|
||||
|
||||
it("allows config updates", () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: false,
|
||||
autoCompactThreshold: 80,
|
||||
compactPreserveWindow: 4000,
|
||||
pruneReclaimThreshold: 8000,
|
||||
userPreference: "ask",
|
||||
undoRetentionWindow: 10,
|
||||
})
|
||||
const config = getCompactionConfig()
|
||||
assert.equal(config.autoCompactEnabled, false)
|
||||
assert.equal(config.autoCompactThreshold, 80)
|
||||
assert.equal(config.userPreference, "ask")
|
||||
assert.equal(config.undoRetentionWindow, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setSessionCompactionState and getSessionCompactionState", () => {
|
||||
it("tracks compaction state for sessions", () => {
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
|
||||
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(isCompacting)
|
||||
})
|
||||
|
||||
it("returns undefined for unknown sessions", () => {
|
||||
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
|
||||
assert.equal(isCompacting, undefined)
|
||||
})
|
||||
|
||||
it("clears compaction state", () => {
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
|
||||
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!isCompacting)
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateTokenReduction", () => {
|
||||
it("calculates correct percentage reduction", () => {
|
||||
const reduction = estimateTokenReduction(10000, 3000)
|
||||
assert.equal(reduction, 70)
|
||||
})
|
||||
|
||||
it("returns 0 when no reduction", () => {
|
||||
const reduction = estimateTokenReduction(10000, 10000)
|
||||
assert.equal(reduction, 0)
|
||||
})
|
||||
|
||||
it("handles zero tokens", () => {
|
||||
const reduction = estimateTokenReduction(0, 0)
|
||||
assert.equal(reduction, 0)
|
||||
})
|
||||
|
||||
it("caps at 100%", () => {
|
||||
const reduction = estimateTokenReduction(10000, -5000)
|
||||
assert.equal(reduction, 100)
|
||||
})
|
||||
|
||||
it("handles small values", () => {
|
||||
const reduction = estimateTokenReduction(100, 50)
|
||||
assert.equal(reduction, 50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeCompactionWrapper", () => {
|
||||
it("compacts session successfully", async () => {
|
||||
const mockStore = {
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getSessionUsage: () => createMockUsage(10000),
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
|
||||
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.mode, "compact")
|
||||
assert.ok(result.token_before > 0)
|
||||
assert.ok(result.token_after >= 0)
|
||||
assert.ok(result.token_reduction_pct >= 0)
|
||||
assert.ok(result.human_summary.length > 0)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("handles missing instance", async () => {
|
||||
const getInstanceMock = mock.fn(() => null)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
|
||||
|
||||
assert.ok(!result.success)
|
||||
assert.equal(result.human_summary, "Instance not found")
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("handles prune mode", async () => {
|
||||
const mockStore = {
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getSessionUsage: () => createMockUsage(10000),
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
|
||||
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.mode, "prune")
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAndTriggerAutoCompact", () => {
|
||||
it("does not trigger when user preference is never", async () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "never",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!shouldCompact)
|
||||
})
|
||||
|
||||
it("returns false when no overflow", async () => {
|
||||
const mockStore = {
|
||||
getSessionUsage: () => createMockUsage(50000),
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!shouldCompact)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("triggers auto-compact when enabled", async () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "auto",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
|
||||
const mockStore = {
|
||||
getSessionUsage: () => createMockUsage(120000),
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(shouldCompact)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
agentModelSelections: {},
|
||||
sessionTasks: {},
|
||||
sessionSkills: {},
|
||||
customAgents: [],
|
||||
}
|
||||
|
||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||
sessionTasks: { ...(source.sessionTasks ?? {}) },
|
||||
sessionSkills: { ...(source.sessionSkills ?? {}) },
|
||||
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,3 +87,20 @@ class MessageStoreBus {
|
||||
}
|
||||
|
||||
export const messageStoreBus = new MessageStoreBus()
|
||||
|
||||
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
|
||||
const store = messageStoreBus.getInstance(instanceId)
|
||||
if (!store) return
|
||||
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
if (messageIds.length <= keepLastN) return
|
||||
|
||||
const messagesToArchive = messageIds.slice(0, -keepLastN)
|
||||
const archiveId = `archived_${sessionId}_${Date.now()}`
|
||||
|
||||
for (const messageId of messagesToArchive) {
|
||||
store.setMessageInfo(messageId, { archived: true } as any)
|
||||
}
|
||||
|
||||
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
|
||||
if (!activeToast || activeToastVersion !== release.version) {
|
||||
dismissActiveToast()
|
||||
activeToast = showToastNotification({
|
||||
title: `CodeNomad ${release.version}`,
|
||||
title: `NomadArch ${release.version}`,
|
||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
||||
variant: "info",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
|
||||
@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
|
||||
import {
|
||||
executeCompactionWrapper,
|
||||
getSessionCompactionState,
|
||||
setSessionCompactionState,
|
||||
setCompactionSuggestion,
|
||||
clearCompactionSuggestion,
|
||||
type CompactionResult,
|
||||
} from "./session-compaction"
|
||||
import { createSession, loadMessages } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
import { loadSkillDetails } from "./skills"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
|
||||
const COMPACTION_SUMMARY_MAX_CHARS = 4000
|
||||
const STREAM_TIMEOUT_MS = 120_000
|
||||
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
|
||||
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
|
||||
|
||||
function markOpencodeZenModelOffline(modelId: string): void {
|
||||
if (typeof window === "undefined" || !modelId) return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||
const raw = window.localStorage.getItem(key)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
const list = Array.isArray(parsed) ? parsed : []
|
||||
if (!list.includes(modelId)) {
|
||||
list.push(modelId)
|
||||
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
|
||||
window.localStorage.setItem(key, JSON.stringify(list))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
|
||||
)
|
||||
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
|
||||
warningThreshold,
|
||||
})
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
|
||||
{
|
||||
title: "Token Budget Warning",
|
||||
confirmLabel: "Compact",
|
||||
cancelLabel: "Continue Anyway",
|
||||
},
|
||||
setCompactionSuggestion(
|
||||
instanceId,
|
||||
sessionId,
|
||||
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setTokenWarningState(instanceId, sessionId, true)
|
||||
await compactSession(instanceId, sessionId)
|
||||
return false
|
||||
}
|
||||
|
||||
setTokenWarningState(instanceId, sessionId, true)
|
||||
return true
|
||||
}
|
||||
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
|
||||
|
||||
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
|
||||
|
||||
const MAX_ATTACHMENT_CHARS = 8000
|
||||
|
||||
function shouldForceEnglish(prompt: string): boolean {
|
||||
const text = prompt.trim()
|
||||
if (!text) return false
|
||||
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
|
||||
return "Respond in English unless the user explicitly requests another language."
|
||||
}
|
||||
|
||||
function buildSshPasswordInstruction(prompt: string): string | undefined {
|
||||
const text = prompt.toLowerCase()
|
||||
if (!text.includes("ssh")) return undefined
|
||||
if (!text.includes("password") && !text.includes("passphrase")) return undefined
|
||||
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
|
||||
}
|
||||
|
||||
function clampText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value
|
||||
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
|
||||
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
|
||||
Promise.resolve(buildLanguageSystemInstruction(prompt)),
|
||||
buildSkillsSystemInstruction(instanceId, sessionId),
|
||||
])
|
||||
if (languageSystem && skillsSystem) {
|
||||
return `${languageSystem}\n\n${skillsSystem}`
|
||||
}
|
||||
return languageSystem || skillsSystem
|
||||
const sshInstruction = buildSshPasswordInstruction(prompt)
|
||||
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
|
||||
if (sections.length === 0) return undefined
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
|
||||
function collectTextSegments(value: unknown, segments: string[]): void {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (typeof record.text === "string") {
|
||||
const trimmed = record.text.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
}
|
||||
if (typeof record.value === "string") {
|
||||
const trimmed = record.value.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
}
|
||||
const content = record.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
collectTextSegments(item, segments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlainTextFromParts(
|
||||
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
|
||||
): string {
|
||||
const segments: string[] = []
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== "object") continue
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
segments.push(part.text)
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
collectTextSegments(part.text, segments)
|
||||
} else if (part.type === "file" && typeof part.filename === "string") {
|
||||
segments.push(`[file: ${part.filename}]`)
|
||||
}
|
||||
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
|
||||
return messages
|
||||
}
|
||||
|
||||
function decodeAttachmentData(data: Uint8Array): string {
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(data)
|
||||
}
|
||||
|
||||
function isTextLikeMime(mime?: string): boolean {
|
||||
if (!mime) return false
|
||||
if (mime.startsWith("text/")) return true
|
||||
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
|
||||
}
|
||||
|
||||
async function buildExternalChatMessagesWithAttachments(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
systemMessage: string | undefined,
|
||||
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
|
||||
): Promise<ExternalChatMessage[]> {
|
||||
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return baseMessages
|
||||
}
|
||||
|
||||
const attachmentMessages: ExternalChatMessage[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const source = attachment?.source
|
||||
if (!source || typeof source !== "object") continue
|
||||
|
||||
let content: string | null = null
|
||||
if (source.type === "text" && typeof source.value === "string") {
|
||||
content = source.value
|
||||
} else if (source.type === "file") {
|
||||
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
|
||||
content = decodeAttachmentData(source.data)
|
||||
} else if (typeof source.path === "string" && source.path.length > 0) {
|
||||
try {
|
||||
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
|
||||
content = typeof response.contents === "string" ? response.contents : null
|
||||
} catch {
|
||||
content = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) continue
|
||||
const filename = attachment.filename || source.path || "attachment"
|
||||
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
|
||||
attachmentMessages.push({
|
||||
role: "user",
|
||||
content: `Attachment: ${filename}\n\n${trimmed}`,
|
||||
})
|
||||
}
|
||||
|
||||
return [...baseMessages, ...attachmentMessages]
|
||||
}
|
||||
|
||||
async function readSseStream(
|
||||
response: Response,
|
||||
onData: (data: string) => void,
|
||||
@@ -396,7 +489,7 @@ async function streamOllamaChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -410,7 +503,7 @@ async function streamOllamaChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -477,7 +570,7 @@ async function streamQwenChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
accessToken: string,
|
||||
resourceUrl: string | undefined,
|
||||
messageId: string,
|
||||
@@ -496,7 +589,7 @@ async function streamQwenChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
resource_url: resourceUrl,
|
||||
}),
|
||||
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -645,7 +738,7 @@ async function streamZAIChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -659,7 +752,7 @@ async function streamZAIChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -868,6 +961,12 @@ async function sendMessage(
|
||||
const now = Date.now()
|
||||
const assistantMessageId = createId("msg")
|
||||
const assistantPartId = createId("part")
|
||||
const externalMessages = await buildExternalChatMessagesWithAttachments(
|
||||
instanceId,
|
||||
sessionId,
|
||||
systemMessage,
|
||||
attachments,
|
||||
)
|
||||
|
||||
store.upsertMessage({
|
||||
id: assistantMessageId,
|
||||
@@ -902,7 +1001,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -913,7 +1012,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -924,7 +1023,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -962,7 +1061,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
token.access_token,
|
||||
token.resource_url,
|
||||
messageId,
|
||||
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
}
|
||||
|
||||
const agent = session.agent || "build"
|
||||
let resolvedCommand = command
|
||||
|
||||
if (command.trim() === "build") {
|
||||
try {
|
||||
const response = await serverApi.fetchAvailablePort()
|
||||
if (response?.port) {
|
||||
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
|
||||
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
|
||||
if (typeof window !== "undefined") {
|
||||
const url = `http://localhost:${response.port}`
|
||||
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to resolve available port for build", { error })
|
||||
}
|
||||
}
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent,
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
|
||||
})
|
||||
|
||||
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
|
||||
clearCompactionSuggestion(instanceId, sessionId)
|
||||
return {
|
||||
...result,
|
||||
token_before: tokenBefore,
|
||||
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function updateSessionModelForSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
log.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
addRecentModelPreference(model)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
@@ -1500,4 +1641,5 @@ export {
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
updateSessionModelForSession,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -147,7 +148,7 @@ function getStoredQwenToken():
|
||||
| null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem("qwen_oauth_token")
|
||||
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
|
||||
const customList = customAgents.map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: "custom",
|
||||
}))
|
||||
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, agentList)
|
||||
next.set(instanceId, [...agentList, ...customList])
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
|
||||
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
|
||||
import { sendMessage } from "./session-actions"
|
||||
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
|
||||
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
import { getDefaultModel } from "./session-models"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
|
||||
checkAndTriggerAutoCompact(instanceId, sessionId)
|
||||
.then((shouldCompact) => {
|
||||
if (!shouldCompact) return
|
||||
if (getSessionCompactionState(instanceId, sessionId)) return
|
||||
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("Failed to check and trigger auto-compact", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
})
|
||||
}
|
||||
|
||||
function isContextLengthError(error: any): boolean {
|
||||
if (!error) return false
|
||||
const errorMessage = error.data?.message || error.message || ""
|
||||
return (
|
||||
errorMessage.includes("maximum context length") ||
|
||||
errorMessage.includes("context_length_exceeded") ||
|
||||
errorMessage.includes("token count exceeds") ||
|
||||
errorMessage.includes("token limit")
|
||||
)
|
||||
}
|
||||
|
||||
function isUnsupportedModelMessage(message: string): boolean {
|
||||
return /model\s+.+\s+not supported/i.test(message)
|
||||
}
|
||||
|
||||
function handleSessionError(instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
|
||||
// Autonomous error recovery for SOLO
|
||||
const solo = getSoloState(instanceId)
|
||||
const sessionId = (event.properties as any)?.sessionID
|
||||
|
||||
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
|
||||
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
|
||||
incrementStep(instanceId)
|
||||
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||
log.error("[SOLO] Failed to send error recovery message", err)
|
||||
})
|
||||
} else {
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a context length error
|
||||
if (isContextLengthError(error)) {
|
||||
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
|
||||
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
|
||||
title: "Compaction failed",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
|
||||
showToastNotification({
|
||||
title: "Context limit exceeded",
|
||||
message: "Compaction is required before continuing.",
|
||||
variant: "warning",
|
||||
duration: 7000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
|
||||
if (sessionId) {
|
||||
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
|
||||
showToastNotification({
|
||||
title: "Compaction required",
|
||||
message: "Click Compact to continue this session.",
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionId && isUnsupportedModelMessage(message)) {
|
||||
showToastNotification({
|
||||
title: "Model not supported",
|
||||
message: "Selected model is not supported by this provider. Reverting to a default model.",
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
|
||||
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
|
||||
getDefaultModel(instanceId, sessionRecord?.agent)
|
||||
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
|
||||
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Default error handling
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
|
||||
import type { MessageInfo } from "../types/message"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
import { getSessionCompactionState } from "./session-compaction"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { withSession } from "./session-state"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { Task, TaskStatus } from "../types/session"
|
||||
import { nanoid } from "nanoid"
|
||||
import { forkSession } from "./session-api"
|
||||
import { createSession } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
|
||||
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
@@ -18,13 +19,32 @@ export async function addTask(
|
||||
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
|
||||
|
||||
let taskSessionId: string | undefined
|
||||
const parentSession = sessions().get(instanceId)?.get(sessionId)
|
||||
const parentAgent = parentSession?.agent || ""
|
||||
const parentModel = parentSession?.model
|
||||
try {
|
||||
console.log("[task-actions] forking session...");
|
||||
const forked = await forkSession(instanceId, sessionId)
|
||||
taskSessionId = forked.id
|
||||
console.log("[task-actions] fork successful", { taskSessionId });
|
||||
console.log("[task-actions] creating new task session...");
|
||||
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
|
||||
taskSessionId = created.id
|
||||
withSession(instanceId, taskSessionId, (taskSession) => {
|
||||
taskSession.parentId = sessionId
|
||||
if (parentAgent) {
|
||||
taskSession.agent = parentAgent
|
||||
}
|
||||
if (parentModel?.providerId && parentModel?.modelId) {
|
||||
taskSession.model = { ...parentModel }
|
||||
}
|
||||
})
|
||||
console.log("[task-actions] task session created", { taskSessionId });
|
||||
} catch (error) {
|
||||
console.error("[task-actions] Failed to fork session for task", error)
|
||||
console.error("[task-actions] Failed to create session for task", error)
|
||||
showToastNotification({
|
||||
title: "Task session unavailable",
|
||||
message: "Continuing in the current session.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
taskSessionId = undefined
|
||||
}
|
||||
|
||||
const newTask: Task = {
|
||||
@@ -34,6 +54,7 @@ export async function addTask(
|
||||
timestamp: Date.now(),
|
||||
messageIds: [],
|
||||
taskSessionId,
|
||||
archived: false,
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (!session.tasks) return
|
||||
session.tasks = session.tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, archived: true } : task,
|
||||
)
|
||||
if (session.activeTaskId === taskId) {
|
||||
session.activeTaskId = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
|
||||
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
|
||||
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
|
||||
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
@@ -29,6 +30,8 @@ export {
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
showFolderSelectionOnStart,
|
||||
setShowFolderSelectionOnStart,
|
||||
instanceTabOrder,
|
||||
setInstanceTabOrder,
|
||||
sessionTabOrder,
|
||||
|
||||
89
packages/ui/src/stores/users.ts
Normal file
89
packages/ui/src/stores/users.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
export interface UserAccount {
|
||||
id: string
|
||||
name: string
|
||||
isGuest?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("users")
|
||||
|
||||
const [users, setUsers] = createSignal<UserAccount[]>([])
|
||||
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
|
||||
const [loadingUsers, setLoadingUsers] = createSignal(false)
|
||||
|
||||
function getElectronApi() {
|
||||
return typeof window !== "undefined" ? window.electronAPI : undefined
|
||||
}
|
||||
|
||||
async function refreshUsers(): Promise<void> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.listUsers) return
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const list = await api.listUsers()
|
||||
setUsers(list ?? [])
|
||||
const active = api.getActiveUser ? await api.getActiveUser() : null
|
||||
setActiveUserSignal(active ?? null)
|
||||
} catch (error) {
|
||||
log.warn("Failed to load users", error)
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(name: string, password: string): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.createUser) return null
|
||||
const user = await api.createUser({ name, password })
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.updateUser) return null
|
||||
const user = await api.updateUser({ id, ...updates })
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
async function deleteUser(id: string): Promise<void> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.deleteUser) return
|
||||
await api.deleteUser({ id })
|
||||
await refreshUsers()
|
||||
}
|
||||
|
||||
async function loginUser(id: string, password?: string): Promise<boolean> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.loginUser) return false
|
||||
const result = await api.loginUser({ id, password })
|
||||
if (result?.success) {
|
||||
setActiveUserSignal(result.user ?? null)
|
||||
await refreshUsers()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function createGuest(): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.createGuest) return null
|
||||
const user = await api.createGuest()
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
export {
|
||||
users,
|
||||
activeUser,
|
||||
loadingUsers,
|
||||
refreshUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
loginUser,
|
||||
createGuest,
|
||||
}
|
||||
Reference in New Issue
Block a user