feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,20 @@
import { invoke } from '@tauri-apps/api/core'
let cachedAgentDocsPath: string | null | undefined
export async function getAgentDocsPath(): Promise<string | undefined> {
if (cachedAgentDocsPath !== undefined) return cachedAgentDocsPath ?? undefined
try {
const path = await invoke<string>('get_agent_docs_path')
cachedAgentDocsPath = path.trim() || null
} catch {
cachedAgentDocsPath = null
}
return cachedAgentDocsPath ?? undefined
}
export function resetAgentDocsPathCacheForTests(): void {
cachedAgentDocsPath = undefined
}

View file

@ -0,0 +1,315 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
buildAgentSystemPromptMock,
formatMessageWithHistoryMock,
nextMessageIdMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
nextMessageIdMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: buildAgentSystemPromptMock,
}))
vi.mock('../utils/ai-chat', () => ({
MAX_HISTORY_TOKENS: 100_000,
formatMessageWithHistory: formatMessageWithHistoryMock,
nextMessageId: nextMessageIdMock,
trimHistory: trimHistoryMock,
}))
import {
appendLocalResponse,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
type AiAgentMessage,
} from './aiAgentConversation'
import {
formatToolLabel,
markReasoningDone,
updateMessage,
updateToolAction,
} from './aiAgentMessageState'
function createMessageStore(initial: AiAgentMessage[] = []) {
let messages = initial
return {
getMessages: () => messages,
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
},
}
}
describe('aiAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks()
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
})
it('creates a missing-agent response using the agent label', () => {
expect(createMissingAgentResponse('codex')).toContain('Codex is not available on this machine')
})
it('appends local responses with the normalized message shape', () => {
nextMessageIdMock.mockReturnValue('msg-local')
const store = createMessageStore()
appendLocalResponse(
store.setMessages,
{ text: 'Explain this', references: [{ path: '/vault/note.md', title: 'Note' }] },
'Sure',
)
expect(store.getMessages()).toEqual([
{
userMessage: 'Explain this',
references: [{ path: '/vault/note.md', title: 'Note' }],
actions: [],
response: 'Sure',
id: 'msg-local',
},
])
})
it('appends streaming messages and returns the generated message id', () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const store = createMessageStore()
const messageId = appendStreamingMessage(store.setMessages, { text: 'Draft reply' })
expect(messageId).toBe('msg-stream')
expect(store.getMessages()).toEqual([
{
userMessage: 'Draft reply',
references: undefined,
actions: [],
isStreaming: true,
id: 'msg-stream',
},
])
})
it('builds a formatted message from completed history only', () => {
const messages: AiAgentMessage[] = [
{
id: 'msg-1',
userMessage: 'First question',
actions: [],
response: 'First answer',
},
{
id: 'msg-2',
userMessage: 'Still streaming',
actions: [],
isStreaming: true,
},
]
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
messages,
{ text: 'Latest question' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
agentDocsPath: undefined,
permissionMode: 'safe',
vaultPaths: undefined,
vaultContext: undefined,
})
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 'Latest question')
expect(result).toEqual({
formattedMessage: 'formatted:Latest question',
systemPrompt: 'SYSTEM',
})
})
it('appends context snapshots to the mode-aware system prompt', () => {
const result = buildFormattedMessage(
{
agent: 'codex',
agentDocsPath: '/docs',
ready: true,
vaultPath: '/vault',
permissionMode: 'power_user',
systemPromptOverride: 'CONTEXT',
},
[],
{ text: 'Prompt' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
agentDocsPath: '/docs',
permissionMode: 'power_user',
vaultPaths: undefined,
vaultContext: 'CONTEXT',
})
expect(result.systemPrompt).toBe('SYSTEM')
})
it('formats explicit note references into the current prompt', () => {
buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
[],
{
text: 'Use this note',
references: [{
path: '/vault/ref.md',
title: 'Ref',
type: 'Note',
content: 'Referenced body',
}],
},
)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('Referenced body'))
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('/vault/ref.md'))
})
})
describe('aiAgentMessageState', () => {
it('updates only the targeted message', () => {
const store = createMessageStore([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [] },
])
updateMessage(store.setMessages, 'edit', (message) => ({
...message,
response: 'Updated',
}))
expect(store.getMessages()).toEqual([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [], response: 'Updated' },
])
})
it('marks reasoning as done only once', () => {
const store = createMessageStore([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [] },
])
markReasoningDone(store.setMessages, 'done')
markReasoningDone(store.setMessages, 'pending')
expect(store.getMessages()).toEqual([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [], reasoningDone: true },
])
})
it('adds new tool actions with the expected labels', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [],
}
expect(updateToolAction(baseMessage, 'Bash', 'tool-1', 'ls')).toMatchObject({
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'pending',
input: 'ls',
}],
})
expect(updateToolAction(baseMessage, 'Write', 'tool-2', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Write',
toolId: 'tool-2',
label: 'Wrote file',
status: 'pending',
}],
})
expect(updateToolAction(baseMessage, 'Edit', 'tool-3', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Edit',
toolId: 'tool-3',
label: 'Edited file',
status: 'pending',
}],
})
})
it('updates an existing tool action without dropping the prior input', () => {
const message: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
}
expect(updateToolAction(message, 'Write', 'tool-1')).toEqual({
...message,
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
})
})
it('describes web and vault tools in Chinese with the current target', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: '请调研',
actions: [],
}
expect(updateToolAction(
baseMessage,
'search_web',
'tool-search',
'{"query":"HoloLake Era 最新版本"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在联网搜索HoloLake Era 最新版本' }],
})
expect(updateToolAction(
baseMessage,
'read_web_page',
'tool-read',
'{"url":"https://example.com/news"}',
'zh-CN',
)).toMatchObject({
actions: [{ label: '正在浏览网页https://example.com/news' }],
})
expect(formatToolLabel(
'read_web_page',
'{"url":"https://example.com/news"}',
'zh-CN',
'done',
)).toBe('已浏览网页https://example.com/news')
})
})

View file

@ -0,0 +1,136 @@
import type { Dispatch, SetStateAction } from 'react'
import type { AiAction } from '../components/AiMessage'
import { buildAgentSystemPrompt } from '../utils/ai-agent'
import {
MAX_HISTORY_TOKENS,
formatMessageWithHistory,
nextMessageId,
trimHistory,
type ChatMessage,
} from '../utils/ai-chat'
import { formatPromptWithReferences, type NoteReference } from '../utils/ai-context'
import type { AiAgentId } from './aiAgents'
import { getAiAgentDefinition } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
import type { AiTarget } from './aiTargets'
import type { AppLocale } from './i18n'
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
id?: string
}
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
export interface AgentExecutionContext {
agent: AiAgentId
sessionId?: string
target?: AiTarget
locale?: AppLocale
ready: boolean
vaultPath: string
vaultPaths?: string[]
agentDocsPath?: string
permissionMode: AiAgentPermissionMode
systemPromptOverride?: string
}
export interface PendingUserPrompt {
text: string
references?: NoteReference[]
}
function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
return messages.filter((message) => !message.localMarker).flatMap((message) => {
const history: ChatMessage[] = [{ role: 'user', content: message.userMessage, id: message.id ?? '' }]
if (message.response) {
history.push({ role: 'assistant', content: message.response, id: `${message.id}-resp` })
}
return history
})
}
export function appendLocalMarker(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
text: string,
): void {
setMessages((current) => [
...current,
{
userMessage: '',
localMarker: text,
actions: [],
id: nextMessageId(),
},
])
}
export function createMissingAgentResponse(agent: AiAgentId): string {
const definition = getAiAgentDefinition(agent)
return `${definition.label} is not available on this machine. Install it or switch the default AI agent in Settings.`
}
export function appendLocalResponse(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
prompt: PendingUserPrompt,
response: string,
): void {
setMessages((current) => [
...current,
{
userMessage: prompt.text,
references: prompt.references,
actions: [],
response,
id: nextMessageId(),
},
])
}
export function appendStreamingMessage(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
prompt: PendingUserPrompt,
): string {
const messageId = nextMessageId()
setMessages((current) => [
...current,
{
userMessage: prompt.text,
references: prompt.references,
actions: [],
isStreaming: true,
id: messageId,
},
])
return messageId
}
export function buildFormattedMessage(
context: AgentExecutionContext,
messages: AiAgentMessage[],
prompt: PendingUserPrompt,
): { formattedMessage: string; systemPrompt: string } {
const systemPrompt = buildAgentSystemPrompt({
agent: context.agent,
agentDocsPath: context.agentDocsPath,
permissionMode: context.permissionMode,
sessionId: context.sessionId,
vaultPaths: context.vaultPaths,
vaultContext: context.systemPromptOverride,
})
const chatHistory = toChatHistory(messages.filter((message) => !message.isStreaming))
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)
const promptText = formatPromptWithReferences(prompt.text, prompt.references)
return {
formattedMessage: formatMessageWithHistory(trimmedHistory, promptText),
systemPrompt,
}
}

View file

@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import {
detectFileOperation,
parseBashFileCreation,
type AgentFileCallbacks,
} from './aiAgentFileOperations'
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileCreated for create_note tool with relative markdown path', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'create_note', input: JSON.stringify({ path: 'note/generated.md' }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).toHaveBeenCalledWith('note/generated.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onFileCreated for create_note tool with Windows absolute path', () => {
const cb = makeCallbacks()
detectFileOperation({
toolName: 'create_note',
input: JSON.stringify({ path: String.raw`D:\Notes\Notas\nota-longa-teste-gerada-2.md` }),
vaultPath: String.raw`D:\Notes\Notas`,
callbacks: cb,
})
expect(cb.onFileCreated).toHaveBeenCalledWith('nota-longa-teste-gerada-2.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Edit', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('refreshes the vault when a writable tool target cannot be resolved', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: 'not-json', vaultPath: VAULT, callbacks: cb })
detectFileOperation({ toolName: 'Edit', vaultPath: VAULT, callbacks: cb })
expect(cb.onVaultChanged).toHaveBeenCalledTimes(2)
})
it('does not treat path prefixes as files inside the vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}-old/note.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalledOnce()
})
it('detects Bash redirects and tee writes for markdown files in the vault', () => {
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` }), vaultPath: VAULT })).toBe('note.md')
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` }), vaultPath: VAULT })).toBe('sub/note.md')
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "data" | tee -a ${VAULT}/new.md` }), vaultPath: VAULT })).toBe('new.md')
})
it('refreshes the vault for Bash when no specific markdown target is found', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Bash', input: JSON.stringify({ command: 'ls -la' }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalledOnce()
})
it('ignores read-only tools and missing callbacks', () => {
const cb = makeCallbacks()
expect(() => detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT })).not.toThrow()
detectFileOperation({ toolName: 'Read', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,279 @@
import { normalizeNotePathSeparators, normalizeVaultRelativePath } from '../utils/notePathIdentity'
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export interface AgentFileOperation {
toolName: string
input?: string
vaultPath: string
callbacks?: AgentFileCallbacks
}
export interface BashFileCreationRequest {
input?: string
vaultPath: string
}
interface OperationContext extends BashFileCreationRequest {
callbacks: AgentFileCallbacks
}
interface PathNotification {
relativePath: string | null
callbacks: AgentFileCallbacks
}
interface ToolInputSource {
input?: string
}
interface ToolInputContext extends ToolInputSource {
vaultPath: string
}
interface VaultRelativePathRequest {
filePath: string
vaultPath: string
}
interface NormalizedToolPath {
value: string
windowsStyle: boolean
}
export function detectFileOperation(operation: AgentFileOperation): void {
if (!operation.callbacks) return
const context = {
input: operation.input,
vaultPath: operation.vaultPath,
callbacks: operation.callbacks,
}
switch (operation.toolName) {
case 'Bash':
notifyBashOperation(context)
return
case 'Write':
notifyWriteOperation(context)
return
case 'create_note':
notifyCreateNoteOperation(context)
return
case 'Edit':
notifyEditOperation(context)
}
}
function notifyBashOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: parseBashFileCreation(context),
callbacks: context.callbacks,
})
}
function notifyWriteOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: markdownPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyCreateNoteOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: markdownCreationPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyEditOperation(context: OperationContext): void {
notifyModifiedPath({
relativePath: markdownPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyCreatedPath({ relativePath, callbacks }: PathNotification): void {
if (relativePath) {
callbacks.onFileCreated?.(relativePath)
} else {
callbacks.onVaultChanged?.()
}
}
function notifyModifiedPath({ relativePath, callbacks }: PathNotification): void {
if (relativePath) {
callbacks.onFileModified?.(relativePath)
} else {
callbacks.onVaultChanged?.()
}
}
function markdownPathFromToolInput(context: ToolInputContext): string | null {
return markdownVaultRelativePath({
filePath: parseFilePath(context),
vaultPath: context.vaultPath,
})
}
function markdownCreationPathFromToolInput(context: ToolInputContext): string | null {
const filePath = parseFilePath(context)
if (!filePath?.endsWith('.md')) return null
const notePath = normalizeToolPath(filePath)
return isRelativePath(notePath)
? safeRelativeMarkdownPath(notePath)
: markdownVaultRelativePath({ filePath, vaultPath: context.vaultPath })
}
function parseFilePath(source: ToolInputSource): string | null {
const parsed = parseToolInput(source)
if (!parsed) return null
return stringField(parsed, ['file_path', 'path'])
}
function parseToolInput(source: ToolInputSource): Record<string, unknown> | null {
if (!source.input) return null
try {
const parsed = JSON.parse(source.input)
return isRecord(parsed) ? parsed : null
} catch {
return null
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringField(record: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const value = Reflect.get(record, key)
if (typeof value === 'string') return value
}
return null
}
function markdownVaultRelativePath(request: {
filePath: string | null
vaultPath: string
}): string | null {
if (!request.filePath || !request.filePath.endsWith('.md')) return null
return toVaultRelative({
filePath: request.filePath,
vaultPath: request.vaultPath,
})
}
function toVaultRelative({ filePath, vaultPath }: VaultRelativePathRequest): string | null {
const vaultRoot = normalizeToolPath(vaultPath)
const notePath = normalizeToolPath(filePath)
return childPathInsideVault(vaultRoot, notePath)
}
function childPathInsideVault(vaultRoot: NormalizedToolPath, notePath: NormalizedToolPath): string | null {
const prefix = `${vaultRoot.value}/`
const caseInsensitive = vaultRoot.windowsStyle || notePath.windowsStyle
const normalizedPrefix = caseInsensitive ? prefix.toLowerCase() : prefix
const normalizedNotePath = caseInsensitive ? notePath.value.toLowerCase() : notePath.value
if (!normalizedNotePath.startsWith(normalizedPrefix)) return null
return notePath.value.slice(prefix.length) || null
}
function normalizeToolPath(value: string): NormalizedToolPath {
return {
value: normalizeNotePathSeparators(value).replace(/\/+$/u, ''),
windowsStyle: /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\'),
}
}
function isRelativePath(path: NormalizedToolPath): boolean {
return !path.value.startsWith('/') && !path.windowsStyle
}
function safeRelativeMarkdownPath(path: NormalizedToolPath): string | null {
const relativePath = normalizeVaultRelativePath(path.value)
if (!relativePath || relativePath.startsWith('../') || relativePath.includes('/../')) return null
return relativePath
}
export function parseBashFileCreation(request: BashFileCreationRequest): string | null {
return markdownVaultRelativePath({
filePath: markdownRedirectTarget(bashCommandFromInput(request)),
vaultPath: request.vaultPath,
})
}
function bashCommandFromInput(source: ToolInputSource): string | null {
const parsed = parseToolInput(source)
if (!parsed) return null
return stringField(parsed, ['command', 'cmd'])
}
function markdownRedirectTarget(command: string | null): string | null {
if (!command) return null
for (let index = 0; index < command.length; index += 1) {
const char = command.at(index)
if (char === '>') {
const target = redirectTargetAfterOperator(command, command.at(index + 1) === '>' ? index + 2 : index + 1)
if (target) return target
}
if (command.startsWith('tee', index)) {
const target = redirectTargetAfterTee(command, index + 3)
if (target) return target
}
}
return null
}
function redirectTargetAfterTee(command: string, startIndex: number): string | null {
if (!isWhitespace(command.at(startIndex))) return null
let index = skipWhitespace(command, startIndex)
if (command.startsWith('-a', index) && isWhitespace(command.at(index + 2))) {
index = skipWhitespace(command, index + 2)
}
return redirectTargetAfterOperator(command, index)
}
function redirectTargetAfterOperator(command: string, startIndex: number): string | null {
const start = skipWhitespace(command, startIndex)
const quote = command.at(start)
const quoted = quote === '"' || quote === "'"
const targetStart = quoted ? start + 1 : start
const targetEnd = readRedirectTargetEnd(command, targetStart, quoted ? quote : null)
const target = command.slice(targetStart, targetEnd)
return target.endsWith('.md') ? target : null
}
function readRedirectTargetEnd(command: string, startIndex: number, quote: string | null): number {
let index = startIndex
while (index < command.length) {
const char = command.at(index)
if (quote ? char === quote : isRedirectTargetTerminator(char)) break
index += 1
}
return index
}
function skipWhitespace(value: string, startIndex: number): number {
let index = startIndex
while (isWhitespace(value.at(index))) index += 1
return index
}
function isWhitespace(value: string | undefined): boolean {
return value === ' ' || value === '\t' || value === '\n' || value === '\r'
}
function isRedirectTargetTerminator(value: string | undefined): boolean {
return value === undefined
|| isWhitespace(value)
|| value === '"'
|| value === "'"
|| value === '|'
|| value === ';'
}

View file

@ -0,0 +1,131 @@
import type { Dispatch, SetStateAction } from 'react'
import type { AiAgentMessage } from './aiAgentConversation'
import type { AppLocale } from './i18n'
export interface ToolInvocation {
tool: string
input?: string
}
export function updateMessage(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
messageId: string,
updater: (message: AiAgentMessage) => AiAgentMessage,
): void {
setMessages((current) => current.map((message) => (message.id === messageId ? updater(message) : message)))
}
export function markReasoningDone(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
messageId: string,
): void {
updateMessage(setMessages, messageId, (message) => (
message.reasoningDone ? message : { ...message, reasoningDone: true }
))
}
type ToolActionStatus = 'pending' | 'done' | 'error'
function parsedToolInput(input?: string): Record<string, unknown> {
if (!input) return {}
try {
const parsed: unknown = JSON.parse(input)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {}
} catch {
return {}
}
}
function toolDetail(input?: string): string | undefined {
const parsed = parsedToolInput(input)
for (const key of ['purpose', 'query', 'url', 'path', 'file_path']) {
const value = parsed[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return undefined
}
function chineseToolVerb(toolName: string, status: ToolActionStatus): string {
const verbs: Record<string, [string, string, string]> = {
magic_brush: ['正在执行', '已完成', '执行失败'],
search_web: ['正在联网搜索', '已联网搜索', '联网搜索失败'],
read_web_page: ['正在浏览网页', '已浏览网页', '网页浏览失败'],
read_guanghu_url: ['正在读取光湖页面', '已读取光湖页面', '光湖页面读取失败'],
search_notes: ['正在搜索知识库', '已搜索知识库', '知识库搜索失败'],
get_vault_context: ['正在了解知识库', '已读取知识库概况', '知识库概况读取失败'],
get_note: ['正在读取页面', '已读取页面', '页面读取失败'],
open_note: ['正在打开页面', '已打开页面', '页面打开失败'],
create_note: ['正在新建页面', '已新建页面', '页面新建失败'],
edit_note: ['正在写入页面', '已写入页面', '页面写入失败'],
delete_note: ['正在删除页面', '已删除页面', '页面删除失败'],
get_fifth_domain_wake_route: ['正在读取第五域路径', '已读取第五域路径', '第五域路径读取失败'],
get_current_time: ['正在校准当前时间', '已校准当前时间', '当前时间校准失败'],
Bash: ['正在运行命令', '已运行命令', '命令运行失败'],
Write: ['正在写入文件', '已写入文件', '文件写入失败'],
Edit: ['正在编辑文件', '已编辑文件', '文件编辑失败'],
Read: ['正在读取文件', '已读取文件', '文件读取失败'],
Glob: ['正在查找文件', '已查找文件', '文件查找失败'],
Grep: ['正在搜索内容', '已搜索内容', '内容搜索失败'],
}
const index = status === 'pending' ? 0 : status === 'done' ? 1 : 2
return verbs[toolName]?.[index] ?? (status === 'pending' ? `正在调用 ${toolName}` : status === 'done' ? `已调用 ${toolName}` : `${toolName} 调用失败`)
}
export function formatToolLabel(
toolName: string,
input?: string,
locale: AppLocale = 'en',
status: ToolActionStatus = 'pending',
): string {
if (locale.startsWith('zh')) {
const detail = toolDetail(input)
const verb = chineseToolVerb(toolName, status)
return detail ? `${verb}${detail}` : verb
}
if (toolName === 'Bash') {
return 'Ran shell command'
}
if (toolName === 'Write') return 'Wrote file'
if (toolName === 'Edit') return 'Edited file'
return toolName
}
export function updateToolAction(
message: AiAgentMessage,
toolName: string,
toolId: string,
input?: string,
locale: AppLocale = 'en',
): AiAgentMessage {
const existing = message.actions.find((action) => action.toolId === toolId)
if (existing) {
return {
...message,
actions: message.actions.map((action) => (
action.toolId === toolId
? {
...action,
input: input ?? action.input,
label: formatToolLabel(toolName, input ?? action.input, locale),
}
: action
)),
}
}
return {
...message,
actions: [
...message.actions,
{
tool: toolName,
toolId,
label: formatToolLabel(toolName, input, locale),
status: 'pending' as const,
input,
},
],
}
}

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
DEFAULT_AI_AGENT_PERMISSION_MODE,
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
} from './aiAgentPermissionMode'
describe('aiAgentPermissionMode', () => {
it('defaults missing, null, and unknown values to vault safe mode', () => {
expect(DEFAULT_AI_AGENT_PERMISSION_MODE).toBe('safe')
expect(normalizeAiAgentPermissionMode(undefined)).toBe('safe')
expect(normalizeAiAgentPermissionMode(null)).toBe('safe')
expect(normalizeAiAgentPermissionMode('danger')).toBe('safe')
})
it('preserves known permission modes and exposes compact labels', () => {
expect(normalizeAiAgentPermissionMode('safe')).toBe('safe')
expect(normalizeAiAgentPermissionMode('power_user')).toBe('power_user')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.short).toBe('Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.control).toBe('Vault Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.power_user.short).toBe('Power User')
})
it('formats a local transcript marker for mode changes', () => {
expect(aiAgentPermissionModeMarker('power_user')).toBe(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
})

View file

@ -0,0 +1,48 @@
import { createTranslator, type AppLocale } from './i18n'
export type AiAgentPermissionMode = 'safe' | 'power_user'
export const DEFAULT_AI_AGENT_PERMISSION_MODE: AiAgentPermissionMode = 'safe'
export const AI_AGENT_PERMISSION_MODE_LABELS: Record<
AiAgentPermissionMode,
{ short: string; control: string }
> = {
safe: {
short: 'Safe',
control: 'Vault Safe',
},
power_user: {
short: 'Power User',
control: 'Power User',
},
}
export function normalizeAiAgentPermissionMode(value: unknown): AiAgentPermissionMode {
return value === 'power_user' ? 'power_user' : DEFAULT_AI_AGENT_PERMISSION_MODE
}
export function aiAgentPermissionModeLabels(
mode: AiAgentPermissionMode,
locale: AppLocale = 'en',
): { short: string; control: string } {
const t = createTranslator(locale)
return mode === 'power_user'
? {
short: t('ai.permission.powerUser.short'),
control: t('ai.permission.powerUser.control'),
}
: {
short: t('ai.permission.safe.short'),
control: t('ai.permission.safe.control'),
}
}
export function aiAgentPermissionModeMarker(
mode: AiAgentPermissionMode,
locale: AppLocale = 'en',
): string {
const t = createTranslator(locale)
const label = aiAgentPermissionModeLabels(mode, locale).short
return t('ai.permission.changed', { label })
}

View file

@ -0,0 +1,435 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import type { AiModelDefinition, AiModelProvider, AiTarget } from './aiTargets'
const {
buildAgentSystemPromptMock,
createStreamCallbacksMock,
formatMessageWithHistoryMock,
hydrateNoteReferencesMock,
nextMessageIdMock,
streamAiAgentMock,
streamAiModelMock,
trackEventMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
createStreamCallbacksMock: vi.fn(() => ({ stream: 'callbacks' })),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
hydrateNoteReferencesMock: vi.fn(async (references: unknown) => references),
nextMessageIdMock: vi.fn(),
streamAiAgentMock: vi.fn(async () => {}),
streamAiModelMock: vi.fn(async () => {}),
trackEventMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: buildAgentSystemPromptMock,
}))
vi.mock('../utils/ai-chat', () => ({
MAX_HISTORY_TOKENS: 100_000,
formatMessageWithHistory: formatMessageWithHistoryMock,
nextMessageId: nextMessageIdMock,
trimHistory: trimHistoryMock,
}))
vi.mock('./aiAgentStreamCallbacks', () => ({
createStreamCallbacks: createStreamCallbacksMock,
}))
vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
vi.mock('../utils/streamAiModel', () => ({
streamAiModel: streamAiModelMock,
}))
vi.mock('../utils/ai-reference-content', () => ({
hydrateNoteReferences: hydrateNoteReferencesMock,
}))
vi.mock('./telemetry', () => ({
trackEvent: trackEventMock,
}))
import {
clearAgentConversation,
sendAgentMessage,
stopAgentMessage,
type AiAgentSessionRuntime,
} from './aiAgentSession'
function createRuntime(
initialMessages: AiAgentMessage[] = [],
initialStatus: AgentStatus = 'idle',
) {
let messages = initialMessages
let status = initialStatus
const messagesRef = { current: messages }
const statusRef = { current: status }
const setMessages = vi.fn((next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
messagesRef.current = messages
})
const setStatus = vi.fn((next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
status = typeof next === 'function' ? next(status) : next
statusRef.current = status
})
const runtime: AiAgentSessionRuntime = {
setMessages,
setStatus,
abortRef: { current: { aborted: true } },
responseAccRef: { current: 'stale response' },
fileCallbacksRef: { current: { onVaultChanged: vi.fn() } },
toolInputMapRef: { current: new Map([['stale-tool', { tool: 'Write', input: '{"path":"/stale.md"}' }]]) },
messagesRef,
statusRef,
}
return {
runtime,
getMessages: () => messages,
getStatus: () => status,
}
}
type RuntimeFixture = ReturnType<typeof createRuntime>
const completedHistory: AiAgentMessage = {
id: 'msg-1',
userMessage: 'Previous question',
actions: [],
response: 'Previous answer',
}
const streamingHistory: AiAgentMessage = {
id: 'msg-2',
userMessage: 'Ignored streaming question',
actions: [],
isStreaming: true,
}
const expectedChatHistory = [
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
]
const apiModelProvider: AiModelProvider = {
id: 'openai',
name: 'OpenAI',
kind: 'open_ai',
base_url: 'https://api.openai.com/v1',
api_key_storage: 'local_file',
api_key_env_var: null,
models: [],
}
const apiModel: AiModelDefinition = {
id: 'gpt-5-nano',
display_name: 'GPT-5 nano',
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: false,
tools: false,
vision: false,
json_mode: false,
reasoning: false,
},
}
const apiTarget: AiTarget = {
kind: 'api_model',
provider: apiModelProvider,
model: apiModel,
id: 'model:openai/gpt-5-nano',
label: 'OpenAI · GPT-5 nano',
shortLabel: 'GPT-5 nano',
}
function expectStreamingRuntimeState(session: RuntimeFixture): void {
expect(session.runtime.abortRef.current.aborted).toBe(false)
expect(session.runtime.abortRef.current.controller).toBeInstanceOf(AbortController)
expect(session.runtime.responseAccRef.current).toBe('')
expect(session.runtime.toolInputMapRef.current.size).toBe(0)
expect(session.getStatus()).toBe('thinking')
expect(session.getMessages().at(-1)).toEqual({
userMessage: 'Latest question',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
actions: [],
isStreaming: true,
id: 'msg-stream',
})
}
function expectFormattedHistoryUsed(): void {
expect(trimHistoryMock).toHaveBeenCalledWith(expectedChatHistory, 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(
expectedChatHistory,
expect.stringContaining('Latest question'),
)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(
expectedChatHistory,
expect.stringContaining('/vault/ref.md'),
)
}
function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
locale: 'it-IT',
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiAgentMock).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
message: expect.stringContaining('formatted:Latest question'),
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
permissionMode: 'power_user',
callbacks: { stream: 'callbacks' },
signal: expect.any(AbortSignal),
}))
}
function expectApiModelStreamingRequest(runtime: RuntimeFixture['runtime']): void {
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
responseSourceLabel: apiTarget.label,
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiModelMock).toHaveBeenCalledWith({
provider: apiModelProvider,
model: apiModel,
message: expect.stringContaining('formatted:Latest question'),
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
vaultPaths: ['/vault', '/team-vault'],
callbacks: { stream: 'callbacks' },
})
}
describe('aiAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
createStreamCallbacksMock.mockReturnValue({ stream: 'callbacks' })
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
streamAiAgentMock.mockResolvedValue(undefined)
hydrateNoteReferencesMock.mockImplementation(async (references: unknown) => references)
trackEventMock.mockClear()
})
async function expectLocalResponse(options: {
messageId: string
context: {
agent: 'claude_code' | 'codex' | 'copilot' | 'opencode' | 'pi' | 'antigravity'
ready: boolean
vaultPath: string
permissionMode: 'safe' | 'power_user'
}
prompt: { text: string; references?: [] }
reason: 'agent_unavailable' | 'missing_vault'
response: string
}) {
nextMessageIdMock.mockReturnValue(options.messageId)
const { runtime, getMessages } = createRuntime()
await sendAgentMessage({
runtime,
context: options.context,
prompt: options.prompt,
})
expect(getMessages()).toEqual([
{
userMessage: options.prompt.text,
references: undefined,
actions: [],
response: options.response,
id: options.messageId,
},
])
expect(streamAiAgentMock).not.toHaveBeenCalled()
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_blocked', {
agent: options.context.agent,
reason: options.reason,
})
}
it('ignores blank prompts and busy runtimes', async () => {
const idleRuntime = createRuntime()
await sendAgentMessage({
runtime: idleRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: ' ' },
})
const busyRuntime = createRuntime([], 'thinking')
await sendAgentMessage({
runtime: busyRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: 'Question' },
})
expect(idleRuntime.getMessages()).toEqual([])
expect(busyRuntime.getMessages()).toEqual([])
expect(streamAiAgentMock).not.toHaveBeenCalled()
})
it('appends local fallback responses when the session cannot stream', async () => {
const fallbackCases = [
{
messageId: 'msg-local',
context: { agent: 'codex', ready: true, vaultPath: '', permissionMode: 'safe' },
prompt: { text: 'Open a note' },
reason: 'missing_vault',
response: 'No vault loaded. Open a vault first.',
},
{
messageId: 'msg-missing',
context: { agent: 'codex', ready: false, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: 'Open a note', references: [] },
reason: 'agent_unavailable',
response:
'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
},
] as const
for (const fallbackCase of fallbackCases) {
await expectLocalResponse(fallbackCase)
}
})
it('starts a streaming session with formatted history and fresh refs', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const session = createRuntime([
completedHistory,
streamingHistory,
])
await sendAgentMessage({
runtime: session.runtime,
context: {
agent: 'codex',
locale: 'it-IT',
ready: true,
vaultPath: '/vault',
permissionMode: 'power_user',
systemPromptOverride: 'OVERRIDE',
},
prompt: {
text: ' Latest question ',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
},
})
expectStreamingRuntimeState(session)
expect(hydrateNoteReferencesMock).toHaveBeenCalledWith([{ path: '/vault/ref.md', title: 'Ref' }])
expectFormattedHistoryUsed()
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',
permissionMode: 'power_user',
vaultContext: 'OVERRIDE',
})
expectStreamingRequest(session.runtime)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_sent', {
agent: 'codex',
permission_mode: 'power_user',
has_context: 1,
reference_count: 1,
history_message_count: 1,
})
})
it('passes vault roots to api model streams for native note tools', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const session = createRuntime([
completedHistory,
streamingHistory,
])
await sendAgentMessage({
runtime: session.runtime,
context: {
agent: 'codex',
target: apiTarget,
ready: true,
vaultPath: '/vault',
vaultPaths: ['/vault', '/team-vault'],
permissionMode: 'safe',
},
prompt: {
text: ' Latest question ',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
},
})
expectStreamingRuntimeState(session)
expectFormattedHistoryUsed()
expectApiModelStreamingRequest(session.runtime)
expect(streamAiAgentMock).not.toHaveBeenCalled()
})
it('clears the conversation and resets runtime refs', () => {
const { runtime } = createRuntime([
{ id: 'msg-1', userMessage: 'Question', actions: [] },
], 'done')
clearAgentConversation(runtime)
expect(runtime.abortRef.current.aborted).toBe(true)
expect(runtime.responseAccRef.current).toBe('')
expect(runtime.toolInputMapRef.current.size).toBe(0)
expect(runtime.setMessages).toHaveBeenCalledWith([])
expect(runtime.setStatus).toHaveBeenCalledWith('idle')
})
it('stops the active stream and marks the streaming message as stopped', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const session = createRuntime()
let streamSignal: AbortSignal | undefined
streamAiAgentMock.mockImplementation(async ({ signal }: { signal?: AbortSignal }) => new Promise<void>((resolve) => {
streamSignal = signal
signal?.addEventListener('abort', () => resolve(), { once: true })
}))
const pending = sendAgentMessage({
runtime: session.runtime,
context: {
agent: 'codex',
ready: true,
vaultPath: '/vault',
permissionMode: 'safe',
},
prompt: { text: ' Latest question ' },
})
await Promise.resolve()
await Promise.resolve()
stopAgentMessage(session.runtime, { agent: 'codex', locale: 'en' })
await pending
expect(streamSignal?.aborted).toBe(true)
expect(session.runtime.abortRef.current.aborted).toBe(true)
expect(session.getStatus()).toBe('idle')
expect(session.getMessages()).toEqual([{
userMessage: 'Latest question',
actions: [],
isStreaming: false,
reasoningDone: true,
response: 'Stopped.',
id: 'msg-stream',
}])
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_stopped', {
agent: 'codex',
had_partial_response: 0,
tool_count: 0,
})
})
})

View file

@ -0,0 +1,271 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import {
appendLocalResponse,
appendLocalMarker,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
type AgentStatus,
type AgentExecutionContext,
type AiAgentMessage,
type PendingUserPrompt,
} from './aiAgentConversation'
import type { AgentFileCallbacks } from './aiAgentFileOperations'
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
import type { ToolInvocation } from './aiAgentMessageState'
import { trackAiAgentMessageBlocked, trackAiAgentMessageSent, trackAiAgentResponseStopped } from './productAnalytics'
import { streamAiAgent } from '../utils/streamAiAgent'
import { streamAiModel } from '../utils/streamAiModel'
import { hydrateNoteReferences } from '../utils/ai-reference-content'
import { createTranslator } from './i18n'
export interface AiAgentAbortState {
aborted: boolean
controller?: AbortController
}
export interface AiAgentSessionRuntime {
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
abortRef: MutableRefObject<AiAgentAbortState>
responseAccRef: MutableRefObject<string>
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
toolInputMapRef: MutableRefObject<Map<string, ToolInvocation>>
messagesRef: MutableRefObject<AiAgentMessage[]>
statusRef: MutableRefObject<AgentStatus>
}
interface SendAgentMessageOptions {
runtime: AiAgentSessionRuntime
context: AgentExecutionContext
prompt: PendingUserPrompt
}
interface RegenerateAgentMessageOptions {
runtime: AiAgentSessionRuntime
context: AgentExecutionContext
messageId: string
}
interface SelectedTargetStreamRequest {
context: AgentExecutionContext
formattedMessage: string
systemPrompt: string
callbacks: ReturnType<typeof createStreamCallbacks>
signal?: AbortSignal
}
function normalizePrompt(prompt: PendingUserPrompt): PendingUserPrompt {
return {
text: prompt.text.trim(),
references: prompt.references && prompt.references.length > 0 ? prompt.references : undefined,
}
}
function completedMessageCount(messages: AiAgentMessage[]): number {
return messages.filter((message) => !message.isStreaming && !message.localMarker).length
}
function shouldIgnorePrompt(status: AgentStatus, prompt: PendingUserPrompt): boolean {
return !prompt.text || status === 'thinking' || status === 'tool-executing'
}
function blockMissingVault(runtime: AiAgentSessionRuntime, context: AgentExecutionContext, prompt: PendingUserPrompt): void {
trackAiAgentMessageBlocked(context.agent, 'missing_vault')
appendLocalResponse(runtime.setMessages, prompt, 'No vault loaded. Open a vault first.')
}
function blockUnavailableAgent(runtime: AiAgentSessionRuntime, context: AgentExecutionContext, prompt: PendingUserPrompt): void {
trackAiAgentMessageBlocked(context.agent, 'agent_unavailable')
appendLocalResponse(
runtime.setMessages,
prompt,
createMissingAgentResponse(context.agent),
)
}
async function streamWithSelectedTarget({
context,
formattedMessage,
systemPrompt,
callbacks,
signal,
}: SelectedTargetStreamRequest): Promise<void> {
if (context.target?.kind === 'api_model') {
await streamAiModel({
provider: context.target.provider,
model: context.target.model,
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
vaultPaths: context.vaultPaths,
callbacks,
})
return
}
await streamAiAgent({
agent: context.agent,
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
vaultPaths: context.vaultPaths,
permissionMode: context.permissionMode,
callbacks,
signal,
})
}
function stoppedResponseText(response: string, locale: AgentExecutionContext['locale']): string {
const stopped = createTranslator(locale ?? 'en')('ai.panel.stoppedResponse')
const partial = response.trim()
return partial ? `${partial}\n\n${stopped}` : stopped
}
export async function sendAgentMessage({
runtime,
context,
prompt,
}: SendAgentMessageOptions): Promise<void> {
const currentStatus = runtime.statusRef.current
const normalizedPrompt = normalizePrompt(prompt)
if (shouldIgnorePrompt(currentStatus, normalizedPrompt)) return
if (!context.vaultPath) {
blockMissingVault(runtime, context, normalizedPrompt)
return
}
if (!context.ready) {
blockUnavailableAgent(runtime, context, normalizedPrompt)
return
}
trackAiAgentMessageSent({
agent: context.agent,
permissionMode: context.permissionMode,
hasContext: !!context.systemPromptOverride,
referenceCount: normalizedPrompt.references?.length ?? 0,
historyMessageCount: completedMessageCount(runtime.messagesRef.current),
})
const controller = new AbortController()
const abortState: AiAgentAbortState = {
aborted: false,
controller,
}
runtime.abortRef.current = abortState
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
const messageId = appendStreamingMessage(runtime.setMessages, normalizedPrompt)
runtime.setStatus('thinking')
const promptForAgent = {
...normalizedPrompt,
references: await hydrateNoteReferences(normalizedPrompt.references),
}
const { formattedMessage, systemPrompt } = buildFormattedMessage(
context,
runtime.messagesRef.current,
promptForAgent,
)
const callbacks = createStreamCallbacks({
agent: context.agent,
responseSourceLabel: context.target?.label,
locale: context.locale,
messageId,
vaultPath: context.vaultPath,
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
abortRef: { current: abortState },
responseAccRef: runtime.responseAccRef,
toolInputMapRef: runtime.toolInputMapRef,
fileCallbacksRef: runtime.fileCallbacksRef,
})
await streamWithSelectedTarget({
context,
formattedMessage,
systemPrompt,
callbacks,
signal: controller.signal,
})
}
export async function regenerateAgentMessage({
runtime,
context,
messageId,
}: RegenerateAgentMessageOptions): Promise<void> {
const currentMessages = runtime.messagesRef.current
const messageIndex = currentMessages.findIndex((message) => message.id === messageId)
const message = currentMessages[messageIndex]
if (!message || message.localMarker || runtime.statusRef.current === 'thinking' || runtime.statusRef.current === 'tool-executing') return
const preservedMessages = currentMessages.slice(0, messageIndex)
runtime.abortRef.current = { aborted: false }
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
runtime.messagesRef.current = preservedMessages
runtime.statusRef.current = 'idle'
runtime.setMessages(preservedMessages)
runtime.setStatus('idle')
await sendAgentMessage({
runtime,
context,
prompt: {
text: message.userMessage,
references: message.references,
},
})
}
export function addAgentLocalMarker(
runtime: Pick<AiAgentSessionRuntime, 'setMessages'>,
text: string,
): void {
appendLocalMarker(runtime.setMessages, text)
}
export function clearAgentConversation(runtime: Pick<AiAgentSessionRuntime, 'abortRef' | 'responseAccRef' | 'toolInputMapRef' | 'setMessages' | 'setStatus'>): void {
runtime.abortRef.current.aborted = true
runtime.abortRef.current.controller?.abort()
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
runtime.setMessages([])
runtime.setStatus('idle')
}
export function stopAgentMessage(
runtime: AiAgentSessionRuntime,
context: Pick<AgentExecutionContext, 'agent' | 'locale'>,
): void {
if (!runtime.abortRef.current.controller || runtime.abortRef.current.aborted) return
runtime.abortRef.current.aborted = true
runtime.abortRef.current.controller.abort()
const response = runtime.responseAccRef.current
const toolCount = runtime.toolInputMapRef.current.size
trackAiAgentResponseStopped(context.agent, response, toolCount)
runtime.setMessages((current) => current.map((message) => (
message.isStreaming
? {
...message,
isStreaming: false,
reasoningDone: true,
response: stoppedResponseText(response, context.locale),
actions: message.actions.map((action) => (
action.status === 'pending' ? { ...action, status: 'error' as const } : action
)),
}
: message
)))
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
runtime.setStatus('idle')
}

View file

@ -0,0 +1,477 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
const { detectFileOperationMock, trackEventMock } = vi.hoisted(() => ({
detectFileOperationMock: vi.fn(),
trackEventMock: vi.fn(),
}))
vi.mock('./aiAgentFileOperations', async (importOriginal) => ({
...await importOriginal<typeof import('./aiAgentFileOperations')>(),
detectFileOperation: detectFileOperationMock,
}))
vi.mock('./telemetry', () => ({
trackEvent: trackEventMock,
}))
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
import { translate } from './i18n'
function createMessageStore(initialMessages: AiAgentMessage[]) {
let messages = initialMessages
return {
getMessages: () => messages,
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
},
}
}
function createStatusStore(initialStatus: AgentStatus = 'idle') {
let status = initialStatus
return {
getStatus: () => status,
setStatus: (next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
status = typeof next === 'function' ? next(status) : next
},
}
}
describe('aiAgentStreamCallbacks', () => {
beforeEach(() => {
vi.clearAllMocks()
trackEventMock.mockClear()
})
it('handles the happy-path lifecycle and refreshes the vault at the end', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore()
const fileCallbacks = { onVaultChanged: vi.fn() }
const responseAccRef = { current: '' }
const toolInputMapRef = { current: new Map<string, { tool: string; input?: string }>() }
const callbacks = createStreamCallbacks({
agent: 'claude_code',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef,
toolInputMapRef,
fileCallbacksRef: { current: fileCallbacks },
})
callbacks.onThinking('step 1')
callbacks.onText('Hello')
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
callbacks.onToolStart('Write', 'tool-1')
callbacks.onToolDone('tool-1', 'saved')
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(responseAccRef.current).toBe('Hello')
expect(toolInputMapRef.current.get('tool-1')).toEqual({
tool: 'Write',
input: '{"path":"/vault/note.md"}',
})
expect(detectFileOperationMock).toHaveBeenCalledWith({
toolName: 'Write',
input: '{"path":"/vault/note.md"}',
vaultPath: '/vault',
callbacks: fileCallbacks,
})
expect(fileCallbacks.onVaultChanged).toHaveBeenCalledTimes(1)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_completed', {
agent: 'claude_code',
had_text: 1,
tool_count: 1,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'done',
input: '{"path":"/vault/note.md"}',
output: 'saved',
}],
isStreaming: false,
reasoning: 'step 1',
reasoningDone: true,
response: 'Hello',
},
])
})
it('truncates large tool output retained in message history', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const callbacks = createStreamCallbacks({
agent: 'claude_code',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: createStatusStore().setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onToolStart('Bash', 'tool-1')
callbacks.onToolDone('tool-1', 'x'.repeat(20_050))
const output = messages.getMessages()[0].actions[0].output
expect(output?.length).toBeLessThan(20_050)
expect(output).toContain('[Tool output truncated: 50 chars omitted]')
})
it('keeps failed MCP create_note results from reporting a created file', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Create a verification note',
actions: [],
isStreaming: true,
},
])
const fileCallbacks = {
onFileCreated: vi.fn(),
onVaultChanged: vi.fn(),
}
const callbacks = createStreamCallbacks({
agent: 'pi',
messageId: 'msg-1',
vaultPath: String.raw`H:\Notes`,
setMessages: messages.setMessages,
setStatus: createStatusStore().setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: fileCallbacks },
})
const output = JSON.stringify({
content: [{
type: 'text',
text: 'Error: Failed to create H:/Notes/test-tool-verification.md: Access is denied',
}],
isError: true,
})
callbacks.onToolStart(
'create_note',
'tool-1',
JSON.stringify({ path: 'H:/Notes/test-tool-verification.md' }),
)
callbacks.onToolDone('tool-1', output)
expect(detectFileOperationMock).not.toHaveBeenCalled()
expect(fileCallbacks.onFileCreated).not.toHaveBeenCalled()
expect(fileCallbacks.onVaultChanged).not.toHaveBeenCalled()
expect(messages.getMessages()[0].actions[0]).toMatchObject({
tool: 'create_note',
toolId: 'tool-1',
status: 'error',
output,
})
})
it('repairs missing sentence boundaries between streamed text chunks', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const responseAccRef = { current: '' }
const callbacks = createStreamCallbacks({
agent: 'claude_code',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: createStatusStore().setStatus,
abortRef: { current: { aborted: false } },
responseAccRef,
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onText("I'll create the Project note now.")
callbacks.onText('Created [[Tolaria Mobile]] as a Project note with a relation to [[frontend]].')
callbacks.onText('It covers three tech stack paths.')
callbacks.onDone()
expect(messages.getMessages()[0].response).toBe(
"I'll create the Project note now. Created [[Tolaria Mobile]] as a Project note with a relation to [[frontend]]. It covers three tech stack paths.",
)
})
it('keeps response normalization compatible with WebKit regex syntax support', () => {
const source = readFileSync(join(process.cwd(), 'src/lib/aiAgentStreamCallbacks.ts'), 'utf8')
expect(source).not.toMatch(/\(\?<[!=]/u)
})
it('marks pending actions as failed when the stream errors', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'pending',
}],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const responseAccRef = { current: 'Partial reply' }
const callbacks = createStreamCallbacks({
agent: 'claude_code',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef,
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onError('boom')
callbacks.onDone()
expect(status.getStatus()).toBe('error')
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_failed', {
agent: 'claude_code',
error_kind: 'stream_error',
had_partial_response: 1,
tool_count: 0,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'error',
}],
isStreaming: false,
reasoningDone: true,
response: 'Partial reply\n\nError: boom',
},
])
expect(trackEventMock).not.toHaveBeenCalledWith('ai_agent_response_completed', expect.anything())
})
it('localizes Pi empty-output stream errors with diagnostic output', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const diagnosticOutput = 'npm warn exec installing pi-mcp-adapter'
const callbacks = createStreamCallbacks({
agent: 'pi',
locale: 'it-IT',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: createStatusStore().setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onError(`tolaria:i18n-error:${JSON.stringify({
key: 'ai.error.pi.emptyOutputWithDiagnostic',
values: { diagnostic_output: diagnosticOutput },
})}`)
expect(messages.getMessages()[0].response).toBe(
`Error: ${translate('it-IT', 'ai.error.pi.emptyOutputWithDiagnostic', { diagnostic_output: diagnosticOutput })}`,
)
})
it.each([
['claude_code', 'Claude Code finished without returning a reply.'],
['pi', 'Pi finished without returning a reply.'],
] as const)('uses the %s label for empty stream responses', (agent, response) => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: '/exit',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
agent,
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_completed', {
agent,
had_text: 0,
tool_count: 0,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
userMessage: '/exit',
actions: [],
isStreaming: false,
reasoningDone: true,
response,
},
])
})
it('gives OpenCode an actionable empty-response message', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Summarize the current note',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
agent: 'opencode',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(messages.getMessages()[0].response).toContain('OpenCode returned no assistant text')
expect(messages.getMessages()[0].response).toContain('provider/model context limit')
expect(messages.getMessages()[0].response).not.toContain('finished without returning a reply')
})
it('names the selected API model when it completes without assistant text', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
agent: 'claude_code',
responseSourceLabel: 'OpenAI · deepseek-v4-pro',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onDone()
expect(messages.getMessages()[0].response).toContain('OpenAI · deepseek-v4-pro')
expect(messages.getMessages()[0].response).not.toContain('Claude Code')
})
it('ignores stream events after the request has been aborted', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const fileCallbacks = { onVaultChanged: vi.fn() }
const callbacks = createStreamCallbacks({
agent: 'claude_code',
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: true } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: fileCallbacks },
})
callbacks.onThinking('ignored')
callbacks.onText('ignored')
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
callbacks.onToolDone('tool-1', 'saved')
callbacks.onError('boom')
callbacks.onDone()
expect(status.getStatus()).toBe('thinking')
expect(messages.getMessages()[0]).toEqual({
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
})
expect(fileCallbacks.onVaultChanged).not.toHaveBeenCalled()
expect(detectFileOperationMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,251 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import { detectFileOperation, type AgentFileCallbacks } from './aiAgentFileOperations'
import {
markReasoningDone,
formatToolLabel,
updateMessage,
updateToolAction,
type ToolInvocation,
} from './aiAgentMessageState'
import { getAiAgentDefinition, type AiAgentId } from './aiAgents'
import {
trackAiAgentResponseCompleted,
trackAiAgentResponseFailed,
} from './productAnalytics'
import type { AppLocale } from './i18n'
import { localizedStreamErrorMessage } from './localizedStreamError'
const MAX_RETAINED_TOOL_OUTPUT_CHARS = 20_000
const ASCII_WORD_RE = /^[A-Za-z0-9_]$/u
const SENTENCE_START_RE = /^[A-ZÀ-ÖØ-Þ]$/u
type AssistantResponseText = string
type StreamErrorMessage = string
type ToolInvocationId = string
type ToolOutputText = string
interface ToolOutputInspection {
output?: ToolOutputText
}
function normalizeAssistantResponseText(response: AssistantResponseText): AssistantResponseText {
let normalized = ''
for (let index = 0; index < response.length; index += 1) {
normalized += response[index]
if (needsSpaceAfterSentencePunctuation(response, index) || needsSpaceAfterWikilink(response, index)) {
normalized += ' '
}
}
return normalized
}
function needsSpaceAfterSentencePunctuation(response: AssistantResponseText, index: number): boolean {
const char = response[index]
if (char !== '.' && char !== '!' && char !== '?') return false
if (isSingleLetterInitialBeforePunctuation(response, index)) return false
return startsSentenceOrWikilink(response, index + 1)
}
function startsSentenceOrWikilink(response: AssistantResponseText, index: number): boolean {
return startsWikilink(response, index) || SENTENCE_START_RE.test(response[index] ?? '')
}
function startsWikilink(response: AssistantResponseText, index: number): boolean {
return response[index] === '[' && response[index + 1] === '['
}
function isSingleLetterInitialBeforePunctuation(response: AssistantResponseText, punctuationIndex: number): boolean {
const initialIndex = punctuationIndex - 1
if (!SENTENCE_START_RE.test(response[initialIndex] ?? '')) return false
const previousChar = response[initialIndex - 1]
return previousChar === undefined || !ASCII_WORD_RE.test(previousChar)
}
function needsSpaceAfterWikilink(response: AssistantResponseText, index: number): boolean {
return response[index - 1] === ']' && response[index] === ']' && SENTENCE_START_RE.test(response[index + 1] ?? '')
}
export interface StreamMutationContext {
agent: AiAgentId
responseSourceLabel?: string
locale?: AppLocale
messageId: string
vaultPath: string
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
abortRef: MutableRefObject<{ aborted: boolean }>
responseAccRef: MutableRefObject<string>
toolInputMapRef: MutableRefObject<Map<string, ToolInvocation>>
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
}
function finalResponseText(
response: AssistantResponseText,
agent: AiAgentId,
responseSourceLabel?: string,
): AssistantResponseText {
if (response.trim()) return normalizeAssistantResponseText(response)
if (agent === 'opencode') {
return [
'OpenCode returned no assistant text.',
'Check the selected provider/model context limit or retry the request.',
'For large active notes, HoloLake Era sends a compact note snapshot and OpenCode can read the full file with get_note(path).',
].join(' ')
}
return `${responseSourceLabel ?? getAiAgentDefinition(agent).label} finished without returning a reply.`
}
function retainedToolOutput({ output }: ToolOutputInspection): ToolOutputText | undefined {
if (!output || output.length <= MAX_RETAINED_TOOL_OUTPUT_CHARS) return output
const omitted = output.length - MAX_RETAINED_TOOL_OUTPUT_CHARS
return [
output.slice(0, MAX_RETAINED_TOOL_OUTPUT_CHARS),
`[Tool output truncated: ${omitted} chars omitted]`,
].join('\n\n')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function toolOutputIndicatesFailure({ output }: ToolOutputInspection): boolean {
const trimmed = output?.trim()
if (!trimmed) return false
if (/^Error:/iu.test(trimmed)) return true
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
return false
}
if (!parsed) return false
if (!isRecord(parsed)) return false
const error = parsed.error
return parsed.isError === true || typeof error === 'string' || isRecord(error)
}
export function createStreamCallbacks(context: StreamMutationContext) {
const {
messageId,
agent,
responseSourceLabel,
locale = 'en',
vaultPath,
setMessages,
setStatus,
abortRef,
responseAccRef,
toolInputMapRef,
fileCallbacksRef,
} = context
let failureTracked = false
let streamFailed = false
return {
onThinking: (chunk: string) => {
if (abortRef.current.aborted) return
updateMessage(setMessages, messageId, (message) => ({
...message,
reasoning: (message.reasoning ?? '') + chunk,
}))
},
onText: (chunk: string) => {
if (abortRef.current.aborted) return
markReasoningDone(setMessages, messageId)
responseAccRef.current += chunk
},
onToolStart: (toolName: string, toolId: string, input?: string) => {
if (abortRef.current.aborted) return
markReasoningDone(setMessages, messageId)
setStatus('tool-executing')
const previous = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? previous?.input })
updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input, locale))
},
onToolDone: (toolId: ToolInvocationId, output?: ToolOutputText) => {
if (abortRef.current.aborted) return
const info = toolInputMapRef.current.get(toolId)
const toolOutput = { output }
const failed = toolOutputIndicatesFailure(toolOutput)
if (info && !failed) {
detectFileOperation({
toolName: info.tool,
input: info.input,
vaultPath,
callbacks: fileCallbacksRef.current,
})
}
updateMessage(setMessages, messageId, (message) => ({
...message,
actions: message.actions.map((action) => (
action.toolId === toolId
? {
...action,
label: formatToolLabel(info?.tool ?? action.tool, info?.input ?? action.input, locale, failed ? 'error' : 'done'),
status: failed ? 'error' as const : 'done' as const,
output: retainedToolOutput(toolOutput),
}
: action
)),
}))
},
onError: (error: StreamErrorMessage) => {
if (abortRef.current.aborted) return
setStatus('error')
streamFailed = true
const displayError = localizedStreamErrorMessage({ message: error, locale })
const partial = normalizeAssistantResponseText(responseAccRef.current)
failureTracked = true
trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size)
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,
reasoningDone: true,
response: partial ? `${partial}\n\nError: ${displayError}` : `Error: ${displayError}`,
actions: message.actions.map((action) => (
action.status === 'pending' ? { ...action, status: 'error' as const } : action
)),
}))
},
onDone: () => {
if (abortRef.current.aborted) return
if (streamFailed) return
setStatus('done')
const finalResponse = finalResponseText(responseAccRef.current, agent, responseSourceLabel)
trackAiAgentResponseCompleted(agent, responseAccRef.current, toolInputMapRef.current.size, failureTracked)
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,
reasoningDone: true,
response: finalResponse,
actions: message.actions.map((action) => (
action.status === 'pending' ? { ...action, status: 'done' as const } : action
)),
}))
fileCallbacksRef.current?.onVaultChanged?.()
},
}
}

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
getNextAiAgentId,
normalizeAiAgentsStatus,
normalizeStoredAiAgent,
resolveDefaultAiAgent,
} from './aiAgents'
describe('aiAgents helpers', () => {
it('normalizes stored agent ids', () => {
expect(normalizeStoredAiAgent('claude_code')).toBe('claude_code')
expect(normalizeStoredAiAgent('codex')).toBe('codex')
expect(normalizeStoredAiAgent('copilot')).toBe('copilot')
expect(normalizeStoredAiAgent('opencode')).toBe('opencode')
expect(normalizeStoredAiAgent('pi')).toBe('pi')
expect(normalizeStoredAiAgent('antigravity')).toBe('antigravity')
expect(normalizeStoredAiAgent('gemini')).toBe('antigravity')
expect(normalizeStoredAiAgent('kiro')).toBe('kiro')
expect(normalizeStoredAiAgent('hermes')).toBe('hermes')
expect(normalizeStoredAiAgent('cursor')).toBeNull()
})
it('falls back to Claude Code as the default agent', () => {
expect(resolveDefaultAiAgent(undefined)).toBe('claude_code')
expect(resolveDefaultAiAgent(null)).toBe('claude_code')
})
it('normalizes raw status payloads', () => {
const statuses = normalizeAiAgentsStatus({
claude_code: { installed: true, version: '1.0.20' },
codex: { installed: false, version: null },
copilot: { installed: true, version: '1.0.58' },
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
antigravity: { installed: true, version: 'Antigravity CLI 1.0.0' },
kiro: { installed: true, version: '0.12.0' },
hermes: { installed: true, version: 'Hermes Agent 0.16.0' },
})
expect(statuses.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
expect(statuses.codex).toEqual({ status: 'missing', version: null })
expect(statuses.copilot).toEqual({ status: 'installed', version: '1.0.58' })
expect(statuses.opencode).toEqual({ status: 'installed', version: '0.3.1' })
expect(statuses.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(statuses.antigravity).toEqual({ status: 'installed', version: 'Antigravity CLI 1.0.0' })
expect(statuses.kiro).toEqual({ status: 'installed', version: '0.12.0' })
expect(statuses.hermes).toEqual({ status: 'installed', version: 'Hermes Agent 0.16.0' })
})
it('normalizes legacy Gemini status payloads to Antigravity', () => {
const statuses = normalizeAiAgentsStatus({
gemini: { installed: true, version: '0.5.1' },
})
expect(statuses.antigravity).toEqual({ status: 'installed', version: '0.5.1' })
})
it('cycles through the supported agents', () => {
expect(getNextAiAgentId('claude_code')).toBe('codex')
expect(getNextAiAgentId('codex')).toBe('copilot')
expect(getNextAiAgentId('copilot')).toBe('opencode')
expect(getNextAiAgentId('opencode')).toBe('pi')
expect(getNextAiAgentId('pi')).toBe('antigravity')
expect(getNextAiAgentId('antigravity')).toBe('kiro')
expect(getNextAiAgentId('kiro')).toBe('hermes')
expect(getNextAiAgentId('hermes')).toBe('claude_code')
})
})

View file

@ -0,0 +1,151 @@
export type AiAgentId = 'claude_code' | 'codex' | 'copilot' | 'opencode' | 'pi' | 'antigravity' | 'kiro' | 'hermes'
type LegacyAiAgentId = 'gemini'
type AiAgentsStatusPayload = Partial<Record<AiAgentId | LegacyAiAgentId, { installed?: boolean | null; version?: string | null }>>
export type AiAgentStatus = 'checking' | 'installed' | 'missing'
export type AiAgentReadiness = 'checking' | 'ready' | 'missing'
export interface AiAgentAvailability {
status: AiAgentStatus
version: string | null
}
export type AiAgentsStatus = Record<AiAgentId, AiAgentAvailability>
export interface AiAgentDefinition {
id: AiAgentId
label: string
shortLabel: string
installUrl: string
}
export const DEFAULT_AI_AGENT: AiAgentId = 'claude_code'
export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [
{
id: 'claude_code',
label: 'Claude Code',
shortLabel: 'Claude',
installUrl: 'https://docs.anthropic.com/en/docs/claude-code',
},
{
id: 'codex',
label: 'Codex',
shortLabel: 'Codex',
installUrl: 'https://developers.openai.com/codex/cli',
},
{
id: 'copilot',
label: 'GitHub Copilot',
shortLabel: 'Copilot',
installUrl: 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli',
},
{
id: 'opencode',
label: 'OpenCode',
shortLabel: 'OpenCode',
installUrl: 'https://opencode.ai/docs/',
},
{
id: 'pi',
label: 'Pi',
shortLabel: 'Pi',
installUrl: 'https://pi.dev',
},
{
id: 'antigravity',
label: 'Antigravity CLI',
shortLabel: 'Antigravity',
installUrl: 'https://antigravity.google/docs/cli/install',
},
{
id: 'kiro',
label: 'Kiro',
shortLabel: 'Kiro',
installUrl: 'https://kiro.dev/docs/cli',
},
{
id: 'hermes',
label: 'Hermes Agent',
shortLabel: 'Hermes',
installUrl: 'https://hermes-agent.nousresearch.com/docs/getting-started/quickstart',
},
] as const
export function createAiAgentAvailability(status: AiAgentStatus = 'checking', version: string | null = null): AiAgentAvailability {
return { status, version }
}
function createAiAgentsStatus(status: AiAgentStatus): AiAgentsStatus {
return Object.fromEntries(
AI_AGENT_DEFINITIONS.map((definition) => [
definition.id,
createAiAgentAvailability(status),
]),
) as AiAgentsStatus
}
export function createCheckingAiAgentsStatus(): AiAgentsStatus {
return createAiAgentsStatus('checking')
}
export function createMissingAiAgentsStatus(): AiAgentsStatus {
return createAiAgentsStatus('missing')
}
export function normalizeStoredAiAgent(value: string | null | undefined): AiAgentId | null {
if (value === 'gemini') return 'antigravity'
if (AI_AGENT_DEFINITIONS.some((definition) => definition.id === value)) return value as AiAgentId
return null
}
export function resolveDefaultAiAgent(value: string | null | undefined): AiAgentId {
return normalizeStoredAiAgent(value) ?? DEFAULT_AI_AGENT
}
export function getAiAgentDefinition(agent: AiAgentId): AiAgentDefinition {
return AI_AGENT_DEFINITIONS.find((definition) => definition.id === agent) ?? AI_AGENT_DEFINITIONS[0]
}
function normalizeAvailability(agent: { installed?: boolean | null; version?: string | null } | null | undefined): AiAgentAvailability {
if (agent?.installed) {
return createAiAgentAvailability('installed', agent.version ?? null)
}
return createAiAgentAvailability('missing', agent?.version ?? null)
}
function payloadForAgent(payload: AiAgentsStatusPayload | null | undefined, agent: AiAgentId) {
return agent === 'antigravity' ? payload?.antigravity ?? payload?.gemini : payload?.[agent]
}
export function normalizeAiAgentsStatus(payload: AiAgentsStatusPayload | null | undefined): AiAgentsStatus {
return Object.fromEntries(
AI_AGENT_DEFINITIONS.map((definition) => [
definition.id,
normalizeAvailability(payloadForAgent(payload, definition.id)),
]),
) as AiAgentsStatus
}
export function getAiAgentAvailability(statuses: Partial<AiAgentsStatus>, agent: AiAgentId): AiAgentAvailability {
return statuses[agent] ?? createAiAgentAvailability('missing')
}
export function isAiAgentsStatusChecking(statuses: Partial<AiAgentsStatus>): boolean {
return AI_AGENT_DEFINITIONS.some((definition) => getAiAgentAvailability(statuses, definition.id).status === 'checking')
}
export function isAiAgentInstalled(statuses: Partial<AiAgentsStatus>, agent: AiAgentId): boolean {
return getAiAgentAvailability(statuses, agent).status === 'installed'
}
export function hasAnyInstalledAiAgent(statuses: Partial<AiAgentsStatus>): boolean {
return AI_AGENT_DEFINITIONS.some((definition) => isAiAgentInstalled(statuses, definition.id))
}
export function getNextAiAgentId(current: AiAgentId): AiAgentId {
const currentIndex = AI_AGENT_DEFINITIONS.findIndex((definition) => definition.id === current)
if (currentIndex < 0) return DEFAULT_AI_AGENT
return AI_AGENT_DEFINITIONS[(currentIndex + 1) % AI_AGENT_DEFINITIONS.length].id
}

View file

@ -0,0 +1,87 @@
import type { AiAgentMessage } from './aiAgentConversation'
interface BrowserNodePreview {
nodeId: string
text: string
}
export interface BrowserObservation {
documentId?: string
url: string
totalCharacters?: number
totalNodes?: number
nodes: BrowserNodePreview[]
status: 'pending' | 'done' | 'error'
}
function parseObject(value?: string): Record<string, unknown> | null {
if (!value) return null
try {
const parsed: unknown = JSON.parse(value)
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null
} catch {
return null
}
}
function stringField(object: Record<string, unknown> | null, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = object?.[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return undefined
}
function numberField(object: Record<string, unknown> | null, ...keys: string[]): number | undefined {
for (const key of keys) {
const value = object?.[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
}
return undefined
}
function browserAction(action: AiAgentMessage['actions'][number]): boolean {
if (action.tool === 'read_web_page' || action.tool === 'read_web_page_nodes') return true
const input = parseObject(action.input)
if (action.tool !== 'magic_brush') return false
const steps = input?.steps
return Array.isArray(steps) && steps.some((step) => (
typeof step === 'object'
&& step !== null
&& ['read_web_page', 'read_web_page_nodes'].includes(String((step as Record<string, unknown>).tool))
))
}
function previewNodes(output: Record<string, unknown> | null): BrowserNodePreview[] {
const raw = output?.node_previews ?? output?.nodes
if (!Array.isArray(raw)) return []
return raw.flatMap((item) => {
if (typeof item !== 'object' || item === null) return []
const node = item as Record<string, unknown>
const nodeId = stringField(node, 'node_id', 'nodeId')
const text = stringField(node, 'text')
return nodeId && text ? [{ nodeId, text }] : []
})
}
export function latestBrowserObservation(messages: AiAgentMessage[]): BrowserObservation | null {
const actions = messages.flatMap((message) => message.actions).filter(browserAction)
const action = actions.at(-1)
if (!action) return null
const input = parseObject(action.input)
const output = parseObject(action.output)
const url = stringField(output, 'url') ?? stringField(input, 'url')
if (!url) return null
return {
documentId: stringField(output, 'document_id', 'documentId'),
url,
totalCharacters: numberField(output, 'total_characters', 'totalCharacters'),
totalNodes: numberField(output, 'total_nodes', 'totalNodes'),
nodes: previewNodes(output),
status: action.status,
}
}

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { areAiFeaturesEnabled } from './aiFeatures'
describe('areAiFeaturesEnabled', () => {
it('defaults AI features on unless the user explicitly disables them', () => {
expect(areAiFeaturesEnabled(undefined)).toBe(true)
expect(areAiFeaturesEnabled({ ai_features_enabled: null })).toBe(true)
expect(areAiFeaturesEnabled({ ai_features_enabled: true })).toBe(true)
expect(areAiFeaturesEnabled({ ai_features_enabled: false })).toBe(false)
})
})

View file

@ -0,0 +1,5 @@
import type { Settings } from '../types'
export function areAiFeaturesEnabled(settings: Pick<Settings, 'ai_features_enabled'> | null | undefined): boolean {
return settings?.ai_features_enabled !== false
}

View file

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
LOCAL_AI_PROVIDER_KINDS,
agentTargetId,
agentTargets,
aiModelProviderCatalog,
aiModelProviderCatalogEntry,
isLocalAiProvider,
normalizeAiModelProviders,
resolveAiTarget,
type AiModelProvider,
} from './aiTargets'
import { AI_AGENT_DEFINITIONS } from './aiAgents'
import type { Settings } from '../types'
function provider(kind: AiModelProvider['kind']): AiModelProvider {
return {
id: ' Demo ',
name: ' Demo Provider ',
kind,
base_url: ' https://example.com/v1 ',
api_key_storage: null,
api_key_env_var: ' DEMO_API_KEY ',
headers: null,
models: [{
id: ' demo-model ',
display_name: ' Demo Model ',
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: true,
tools: false,
vision: false,
json_mode: true,
reasoning: false,
},
}],
}
}
describe('ai target provider contract', () => {
it('builds selectable targets for every supported coding agent', () => {
expect(agentTargets().map((target) => target.id)).toEqual(
AI_AGENT_DEFINITIONS.map((definition) => agentTargetId(definition.id)),
)
})
it('resolves Copilot as a persisted default agent target', () => {
const target = resolveAiTarget({
default_ai_agent: 'claude_code',
default_ai_target: 'agent:copilot',
} as Settings)
expect(target).toMatchObject({
kind: 'agent',
agent: 'copilot',
id: 'agent:copilot',
label: 'GitHub Copilot',
})
})
it('accepts legacy agent ids saved in the default target field', () => {
const target = resolveAiTarget({
default_ai_agent: 'claude_code',
default_ai_target: 'kiro',
} as Settings)
expect(target).toMatchObject({
kind: 'agent',
agent: 'kiro',
id: 'agent:kiro',
})
})
it('uses the legacy default agent when a saved agent target is stale', () => {
const target = resolveAiTarget({
default_ai_agent: 'kiro',
default_ai_target: 'agent:claude_code',
} as Settings)
expect(target).toMatchObject({
kind: 'agent',
agent: 'kiro',
id: 'agent:kiro',
})
})
it('keeps provider defaults in one catalog with stable grouping metadata', () => {
const entries = aiModelProviderCatalog()
const kinds = entries.map((entry) => entry.kind)
expect(kinds).toEqual([
'ollama',
'lm_studio',
'open_ai',
'anthropic',
'gemini',
'open_router',
'open_ai_compatible',
])
expect(new Set(kinds).size).toBe(kinds.length)
expect(LOCAL_AI_PROVIDER_KINDS).toEqual(['ollama', 'lm_studio'])
expect(aiModelProviderCatalogEntry('anthropic')).toMatchObject({
name: 'Anthropic',
base_url: 'https://api.anthropic.com/v1',
api_key_storage: 'local_file',
api_key_env_var: 'ANTHROPIC_API_KEY',
default_model_id: 'claude-3-5-sonnet-latest',
local: false,
})
expect(aiModelProviderCatalogEntry('open_ai_compatible')).toMatchObject({
base_url: 'https://api.example.com/v1',
api_key_env_var: 'OPENAI_API_KEY',
local: false,
})
})
it('normalizes saved providers while using the catalog for local/provider classification', () => {
const normalized = normalizeAiModelProviders([
provider('open_ai_compatible'),
{ ...provider('ollama'), id: ' ', name: 'Missing ID' },
])
expect(normalized).toHaveLength(1)
expect(normalized[0]).toMatchObject({
id: 'demo',
name: 'Demo Provider',
base_url: 'https://example.com/v1',
api_key_env_var: 'DEMO_API_KEY',
api_key_storage: 'env',
})
expect(normalized[0].models[0]).toMatchObject({
id: 'demo-model',
display_name: 'Demo Model',
})
expect(isLocalAiProvider(provider('lm_studio'))).toBe(true)
expect(isLocalAiProvider(provider('open_router'))).toBe(false)
})
})

View file

@ -0,0 +1,216 @@
import {
AI_AGENT_DEFINITIONS,
DEFAULT_AI_AGENT,
getAiAgentAvailability,
normalizeStoredAiAgent,
type AiAgentId,
type AiAgentsStatus,
} from './aiAgents'
import providerCatalog from '../shared/aiModelProviderCatalog.json' with { type: 'json' }
import type { Settings } from '../types'
import type { TranslationKey } from './i18n'
export type AiModelProviderKind = 'open_ai' | 'anthropic' | 'open_ai_compatible' | 'ollama' | 'lm_studio' | 'open_router' | 'gemini'
export type AiTargetKind = 'agent' | 'api_model'
export type AiModelApiKeyStorage = 'none' | 'env' | 'local_file'
export interface AiModelCapabilities {
streaming: boolean
tools: boolean
vision: boolean
json_mode: boolean
reasoning: boolean
}
export interface AiModelDefinition {
id: string
display_name?: string | null
context_window?: number | null
max_output_tokens?: number | null
capabilities: AiModelCapabilities
}
export interface AiModelProvider {
id: string
name: string
kind: AiModelProviderKind
base_url?: string | null
api_key_storage?: AiModelApiKeyStorage | null
api_key_env_var?: string | null
headers?: Record<string, string> | null
models: AiModelDefinition[]
}
export interface AiModelProviderCatalogEntry {
kind: AiModelProviderKind
name: string
label_key: TranslationKey
base_url: string
runtime_base_url: string | null
default_model_id: string
api_key_storage: AiModelApiKeyStorage
api_key_env_var: string | null
local: boolean
}
export type AiTarget =
| { kind: 'agent'; agent: AiAgentId; id: string; label: string; shortLabel: string }
| { kind: 'api_model'; provider: AiModelProvider; model: AiModelDefinition; id: string; label: string; shortLabel: string }
export type AiModelTarget = Extract<AiTarget, { kind: 'api_model' }>
export const AI_TARGET_PREFIX_AGENT = 'agent:'
export const AI_TARGET_PREFIX_MODEL = 'model:'
const AI_MODEL_PROVIDER_CATALOG = providerCatalog as readonly AiModelProviderCatalogEntry[]
const AI_MODEL_PROVIDER_CATALOG_BY_KIND = new Map<AiModelProviderKind, AiModelProviderCatalogEntry>(
AI_MODEL_PROVIDER_CATALOG.map((entry) => [entry.kind, entry]),
)
export const LOCAL_AI_PROVIDER_KINDS: readonly AiModelProviderKind[] = AI_MODEL_PROVIDER_CATALOG
.filter((entry) => entry.local)
.map((entry) => entry.kind)
export const DEFAULT_MODEL_CAPABILITIES: AiModelCapabilities = {
streaming: false,
tools: false,
vision: false,
json_mode: false,
reasoning: false,
}
export function aiModelProviderCatalog(): readonly AiModelProviderCatalogEntry[] {
return AI_MODEL_PROVIDER_CATALOG
}
export function aiModelProviderCatalogEntry(kind: AiModelProviderKind): AiModelProviderCatalogEntry {
const entry = AI_MODEL_PROVIDER_CATALOG_BY_KIND.get(kind)
if (!entry) throw new Error(`Unknown AI model provider kind: ${kind}`)
return entry
}
export function agentTargetId(agent: AiAgentId): string {
return `${AI_TARGET_PREFIX_AGENT}${agent}`
}
export function modelTargetId(providerId: string, modelId: string): string {
return `${AI_TARGET_PREFIX_MODEL}${providerId}/${modelId}`
}
export function configuredModelTargets(providers: AiModelProvider[] | null | undefined): AiModelTarget[] {
return (providers ?? []).flatMap((provider) => provider.models.map((model) => {
const displayName = model.display_name || model.id
return {
kind: 'api_model' as const,
provider,
model,
id: modelTargetId(provider.id, model.id),
label: `${provider.name} · ${displayName}`,
shortLabel: displayName,
}
}))
}
export function agentTargets(): AiTarget[] {
return AI_AGENT_DEFINITIONS.map((definition) => {
return {
kind: 'agent' as const,
agent: definition.id,
id: agentTargetId(definition.id),
label: definition.label,
shortLabel: definition.shortLabel,
}
})
}
export function resolveAiTarget(settings: Settings): AiTarget {
const providers = normalizeAiModelProviders(settings.ai_model_providers)
const agents = agentTargets()
const targets = [...agents, ...configuredModelTargets(providers)]
const storedLegacyAgent = normalizeStoredAiAgent(settings.default_ai_agent)
const legacyAgent = storedLegacyAgent ?? DEFAULT_AI_AGENT
const target = resolveStoredAiTarget(settings.default_ai_target, targets)
if (target) {
if (shouldPreferLegacyAgent(target, storedLegacyAgent)) return agentTargetFor(agents, legacyAgent) ?? target
return target
}
return agentTargetFor(agents, legacyAgent) ?? agents[0]
}
export function targetAgent(target: AiTarget): AiAgentId {
return target.kind === 'agent' ? target.agent : DEFAULT_AI_AGENT
}
function shouldPreferLegacyAgent(target: AiTarget, storedLegacyAgent: AiAgentId | null): boolean {
if (target.kind !== 'agent') return false
if (!storedLegacyAgent) return false
if (target.agent !== DEFAULT_AI_AGENT) return false
return target.agent !== storedLegacyAgent
}
function agentTargetFor(agents: AiTarget[], agent: AiAgentId): AiTarget | undefined {
return agents.find((candidate) => candidate.kind === 'agent' && candidate.agent === agent)
}
function resolveStoredAiTarget(storedTarget: string | null | undefined, targets: AiTarget[]): AiTarget | null {
const target = storedTarget?.trim()
if (!target) return null
const exactTarget = targets.find((candidate) => candidate.id === target)
if (exactTarget) return exactTarget
const legacyAgent = normalizeStoredAiAgent(target)
if (!legacyAgent) return null
return targets.find((candidate) => candidate.kind === 'agent' && candidate.agent === legacyAgent) ?? null
}
export function normalizeAiModelProviders(providers: AiModelProvider[] | null | undefined): AiModelProvider[] {
return (providers ?? []).map(normalizeAiModelProvider).filter((provider): provider is AiModelProvider => provider !== null)
}
export function normalizeAiModelProvider(provider: AiModelProvider): AiModelProvider | null {
const id = provider.id.trim().toLowerCase()
const name = provider.name.trim()
const models = provider.models.map(normalizeAiModelDefinition).filter((model): model is AiModelDefinition => model !== null)
if (!id || !name || models.length === 0) return null
return {
...provider,
id,
name,
base_url: emptyToNull(provider.base_url),
api_key_storage: normalizeApiKeyStorage(provider),
api_key_env_var: emptyToNull(provider.api_key_env_var),
models,
}
}
function normalizeApiKeyStorage(provider: AiModelProvider): 'none' | 'env' | 'local_file' {
if (provider.api_key_storage === 'local_file') return 'local_file'
if (provider.api_key_storage === 'env' || emptyToNull(provider.api_key_env_var)) return 'env'
return 'none'
}
function normalizeAiModelDefinition(model: AiModelDefinition): AiModelDefinition | null {
const id = model.id.trim()
if (!id) return null
return {
...model,
id,
display_name: emptyToNull(model.display_name),
capabilities: model.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
}
}
function emptyToNull(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function isLocalAiProvider(provider: AiModelProvider): boolean {
return aiModelProviderCatalogEntry(provider.kind).local
}
export function aiTargetReady(target: AiTarget, statuses: AiAgentsStatus): boolean {
if (target.kind === 'api_model') return true
return getAiAgentAvailability(statuses, target.agent).status === 'installed'
}

View file

@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
const { invokeMock, isTauriState } = vi.hoisted(() => ({
invokeMock: vi.fn(),
isTauriState: { value: false },
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: invokeMock,
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriState.value,
}))
function createStorageMock() {
const store = new Map<string, string>()
let writesFail = false
return {
storage: {
get length() {
return store.size
},
clear: vi.fn(() => store.clear()),
getItem: vi.fn((key: string) => store.get(key) ?? null),
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
setItem: vi.fn((key: string, value: string) => {
if (writesFail) throw new Error('Quota exceeded')
store.set(key, value)
}),
} as Storage,
failWrites() {
writesFail = true
},
}
}
describe('aiWorkspaceSessionStore', () => {
let storageMock: ReturnType<typeof createStorageMock>
beforeEach(() => {
storageMock = createStorageMock()
vi.stubGlobal('localStorage', storageMock.storage)
invokeMock.mockReset()
isTauriState.value = false
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('hydrates workspace session messages after module reload', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'chat-1': {
messages: [
{
userMessage: 'Remember this',
actions: [],
response: 'Still here',
id: 'message-1',
},
],
status: 'done',
},
}))
const store = await import('./aiWorkspaceSessionStore')
expect(store.aiWorkspaceSessionSnapshot('chat-1')).toEqual({
messages: [
expect.objectContaining({
userMessage: 'Remember this',
response: 'Still here',
}),
],
status: 'done',
})
})
it('restores interrupted stored sessions as idle completed history', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'chat-1': {
messages: [
{
userMessage: 'Halfway',
actions: [],
response: 'Partial',
isStreaming: true,
id: 'message-1',
},
],
status: 'thinking',
},
}))
const store = await import('./aiWorkspaceSessionStore')
expect(store.aiWorkspaceSessionSnapshot('chat-1')).toEqual({
messages: [
expect.objectContaining({
userMessage: 'Halfway',
response: 'Partial',
isStreaming: false,
}),
],
status: 'idle',
})
})
it('keeps in-memory session history when localStorage persistence fails', async () => {
const store = await import('./aiWorkspaceSessionStore')
storageMock.failWrites()
store.setAiWorkspaceSessionMessages('chat-1', [
{
userMessage: 'Still available',
actions: [],
response: 'In memory',
id: 'message-1',
},
])
expect(store.aiWorkspaceSessionSnapshot('chat-1').messages).toEqual([
expect.objectContaining({
userMessage: 'Still available',
response: 'In memory',
}),
])
})
it('permanently removes a deleted conversation transcript', async () => {
const store = await import('./aiWorkspaceSessionStore')
store.setAiWorkspaceSessionMessages('chat-1', [{
userMessage: 'Remove me',
actions: [],
response: 'Deleted locally',
id: 'message-1',
}])
store.deleteAiWorkspaceSession('chat-1')
expect(store.aiWorkspaceSessionsSnapshot()['chat-1']).toBeUndefined()
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')['chat-1']).toBeUndefined()
})
it('merges native and local histories instead of hiding either source', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'local-chat': {
messages: [{ userMessage: 'Local', actions: [], response: 'History', id: 'local-message' }],
status: 'done',
},
}))
isTauriState.value = true
invokeMock.mockImplementation(async (command: string) => {
if (command === 'get_ai_workspace_sessions') {
return {
'native-chat': {
messages: [{ userMessage: 'Native', actions: [], response: 'History', id: 'native-message' }],
status: 'done',
},
}
}
return undefined
})
const store = await import('./aiWorkspaceSessionStore')
await vi.waitFor(() => {
expect(store.aiWorkspaceSessionSnapshot('local-chat').messages).toHaveLength(1)
expect(store.aiWorkspaceSessionSnapshot('native-chat').messages).toHaveLength(1)
})
})
})

View file

@ -0,0 +1,269 @@
import type { Dispatch, SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import { isTauri } from '../mock-tauri'
import { createCrossWindowPersistedStore, type CrossWindowStoreReadReason } from './crossWindowPersistedStore'
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-sessions'
const NATIVE_WRITE_DEBOUNCE_MS = 250
export interface AiWorkspaceSessionSnapshot {
messages: AiAgentMessage[]
status: AgentStatus
}
type MessageId = string
type SessionId = string
export type AiWorkspaceSessionMap = Record<SessionId, AiWorkspaceSessionSnapshot>
type SessionMap = AiWorkspaceSessionMap
const EMPTY_SESSION: AiWorkspaceSessionSnapshot = {
messages: [],
status: 'idle',
}
const sessionStore = createCrossWindowPersistedStore<SessionMap>({
broadcastChannelName: BROADCAST_CHANNEL,
broadcastMessage: { type: 'ai-workspace-sessions-updated' },
emptySnapshot: {},
sanitizeStoredValue: normalizeStoredSessionsForReason,
storageKey: STORAGE_KEY,
})
let storeVersion = 0
let nativeWriteTimer: ReturnType<typeof setTimeout> | null = null
let nativeWriteInFlight = false
let pendingNativeSessions: SessionMap | null = null
function isSessionSnapshot(value: unknown): value is AiWorkspaceSessionSnapshot {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<AiWorkspaceSessionSnapshot>
return Array.isArray(candidate.messages) && typeof candidate.status === 'string'
}
function normalizeStoredStatus(value: unknown, resetRunningStatus: boolean): AgentStatus {
switch (value) {
case 'idle':
case 'done':
case 'error':
return value
case 'thinking':
case 'tool-executing':
return resetRunningStatus ? 'idle' : value
default:
return 'idle'
}
}
function normalizeStoredMessages(messages: AiAgentMessage[], resetRunningStatus: boolean): AiAgentMessage[] {
if (!resetRunningStatus) return messages
return messages.map((message) => (
message.isStreaming ? { ...message, isStreaming: false } : message
))
}
function normalizeStoredSessions(value: unknown, resetRunningStatus: boolean): SessionMap {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, AiWorkspaceSessionSnapshot] => (
typeof entry[0] === 'string' && isSessionSnapshot(entry[1])
)).map(([sessionId, session]) => [
sessionId,
{
messages: normalizeStoredMessages(session.messages, resetRunningStatus),
status: normalizeStoredStatus(session.status, resetRunningStatus),
},
]),
)
}
function normalizeStoredSessionsForReason(
value: unknown,
reason: CrossWindowStoreReadReason,
): SessionMap {
return normalizeStoredSessions(value, reason !== 'storage')
}
function mergeStoredSessions(localSessions: SessionMap, nativeSessions: SessionMap): SessionMap {
const merged = { ...nativeSessions }
for (const [sessionId, localSession] of Object.entries(localSessions)) {
const nativeSession = nativeSessions[sessionId]
if (!nativeSession || localSession.messages.length >= nativeSession.messages.length) {
merged[sessionId] = localSession
}
}
return merged
}
async function readNativeSessions(): Promise<SessionMap> {
if (!isTauri()) return {}
try {
const stored = await invoke<unknown>('get_ai_workspace_sessions')
return normalizeStoredSessions(stored, true)
} catch {
return {}
}
}
async function flushNativeSessionsWrite(): Promise<void> {
if (!isTauri() || nativeWriteInFlight || !pendingNativeSessions) return
nativeWriteInFlight = true
const nextSessions = pendingNativeSessions
pendingNativeSessions = null
nativeWriteTimer = null
try {
await invoke('save_ai_workspace_sessions', { sessions: nextSessions })
} catch {
// Transcript persistence should never interrupt the chat UI.
} finally {
nativeWriteInFlight = false
if (pendingNativeSessions) scheduleNativeSessionsWrite(pendingNativeSessions)
}
}
function scheduleNativeSessionsWrite(nextSessions: SessionMap): void {
if (!isTauri()) return
pendingNativeSessions = nextSessions
if (nativeWriteTimer || nativeWriteInFlight) return
nativeWriteTimer = setTimeout(() => {
void flushNativeSessionsWrite()
}, NATIVE_WRITE_DEBOUNCE_MS)
}
function publishSessions(nextSessions: SessionMap): void {
storeVersion += 1
sessionStore.publishSnapshot(nextSessions)
scheduleNativeSessionsWrite(nextSessions)
}
function publishSessionUpdate(
sessionId: SessionId,
update: (current: AiWorkspaceSessionSnapshot) => AiWorkspaceSessionSnapshot,
): void {
const current = aiWorkspaceSessionSnapshot(sessionId)
publishSessions({
...sessionStore.getSnapshot(),
[sessionId]: update(current),
})
}
async function syncFromNativeStorage(): Promise<void> {
const loadVersion = storeVersion
const nativeSessions = await readNativeSessions()
if (storeVersion !== loadVersion) return
const mergedSessions = mergeStoredSessions(sessionStore.getSnapshot(), nativeSessions)
sessionStore.replaceSnapshot(mergedSessions)
sessionStore.writeStoredSnapshot(mergedSessions)
if (Object.keys(mergedSessions).length > 0) scheduleNativeSessionsWrite(mergedSessions)
}
function ensureSessionStoreSync(): void {
if (typeof window === 'undefined') return
sessionStore.ensureCrossWindowSync()
window.addEventListener('pagehide', () => {
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
nativeWriteTimer = null
void flushNativeSessionsWrite()
})
}
ensureSessionStoreSync()
void syncFromNativeStorage()
export function aiWorkspaceSessionSnapshot(sessionId: SessionId): AiWorkspaceSessionSnapshot {
return sessionStore.getSnapshot()[sessionId] ?? EMPTY_SESSION
}
export function aiWorkspaceSessionsSnapshot(): AiWorkspaceSessionMap {
return sessionStore.getSnapshot()
}
export function subscribeAiWorkspaceSessions(listener: () => void): () => void {
return sessionStore.subscribe(listener)
}
export function subscribeAiWorkspaceSession(_sessionId: SessionId, listener: () => void): () => void {
return sessionStore.subscribe(listener)
}
export function setAiWorkspaceSessionMessages(
sessionId: SessionId,
next: SetStateAction<AiAgentMessage[]>,
): void {
publishSessionUpdate(sessionId, (current) => {
const messages = typeof next === 'function' ? next(current.messages) : next
return {
...current,
messages,
}
})
}
export function setAiWorkspaceSessionStatus(
sessionId: SessionId,
next: SetStateAction<AgentStatus>,
): void {
publishSessionUpdate(sessionId, (current) => {
const status = typeof next === 'function' ? next(current.status) : next
return {
...current,
status,
}
})
}
export function resetAiWorkspaceSession(sessionId: SessionId): void {
publishSessions({
...sessionStore.getSnapshot(),
[sessionId]: EMPTY_SESSION,
})
}
export function deleteAiWorkspaceSession(sessionId: SessionId): void {
const nextSessions = { ...sessionStore.getSnapshot() }
delete nextSessions[sessionId]
publishSessions(nextSessions)
}
export function cloneAiWorkspaceSessionUntilMessage(
sourceSessionId: SessionId,
targetSessionId: SessionId,
messageId: MessageId,
): void {
const source = aiWorkspaceSessionSnapshot(sourceSessionId)
const messageIndex = source.messages.findIndex((message) => message.id === messageId)
const messages = messageIndex >= 0 ? source.messages.slice(0, messageIndex + 1) : source.messages
publishSessions({
...sessionStore.getSnapshot(),
[targetSessionId]: {
messages: messages.map((message) => ({ ...message, isStreaming: false })),
status: 'idle',
},
})
}
export function aiWorkspaceSessionDispatchers(sessionId: SessionId): {
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
} {
return {
setMessages: (next) => setAiWorkspaceSessionMessages(sessionId, next),
setStatus: (next) => setAiWorkspaceSessionStatus(sessionId, next),
}
}
export function resetAiWorkspaceSessionStoreForTests(): void {
storeVersion = 0
pendingNativeSessions = null
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
nativeWriteTimer = null
nativeWriteInFlight = false
sessionStore.publishSnapshot({})
}

View file

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: 'Snippet',
wordCount: 42,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: true,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
...overrides,
})
describe('aiWorkspaceWindowSharedContext', () => {
let store: Record<string, string>
beforeEach(() => {
vi.resetModules()
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
})
vi.stubGlobal('BroadcastChannel', class {
onmessage: (() => void) | null = null
postMessage = vi.fn()
})
})
it('preserves note metadata needed by the AI context builder', async () => {
const {
aiWorkspaceWindowSharedContextSnapshot,
publishAiWorkspaceWindowSharedContext,
} = await import('./aiWorkspaceWindowSharedContext')
const activeEntry = makeEntry({
path: '/vault/active.md',
title: 'Active',
aliases: ['A'],
belongsTo: ['[[Parent]]'],
relatedTo: ['[[Sibling]]'],
outgoingLinks: ['Linked'],
properties: { Owner: 'Alice' },
relationships: { People: ['[[Alice]]'] },
wordCount: 128,
})
publishAiWorkspaceWindowSharedContext({
activeEntry,
activeNoteContent: '# Active',
entries: [activeEntry],
openTabs: [activeEntry],
vaultPath: '/vault',
vaultPaths: ['/vault'],
})
const snapshot = aiWorkspaceWindowSharedContextSnapshot()
expect(snapshot.activeEntry?.outgoingLinks).toEqual(['Linked'])
expect(snapshot.activeEntry?.belongsTo).toEqual(['[[Parent]]'])
expect(snapshot.activeEntry?.relatedTo).toEqual(['[[Sibling]]'])
expect(snapshot.activeEntry?.relationships).toEqual({ People: ['[[Alice]]'] })
expect(snapshot.activeEntry?.properties).toEqual({ Owner: 'Alice' })
expect(snapshot.activeEntry?.wordCount).toBe(128)
const stored = JSON.parse(store[STORAGE_KEY])
expect(stored.activeEntry.outgoingLinks).toEqual(['Linked'])
expect(stored.activeEntry.relationships).toEqual({ People: ['[[Alice]]'] })
})
})

View file

@ -0,0 +1,127 @@
import type { AiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow'
import type { NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { createCrossWindowPersistedStore } from './crossWindowPersistedStore'
const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1'
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-window-context'
export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext {
activeEntry?: VaultEntry | null
activeNoteContent?: string | null
entries?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
openTabs?: VaultEntry[]
}
const EMPTY_CONTEXT: AiWorkspaceWindowSharedContext = {}
const contextStore = createCrossWindowPersistedStore<AiWorkspaceWindowSharedContext>({
broadcastChannelName: BROADCAST_CHANNEL,
broadcastMessage: { type: 'ai-workspace-window-context-updated' },
emptySnapshot: EMPTY_CONTEXT,
sanitizeStoredValue: (value) => sanitizeContext(value),
storageKey: STORAGE_KEY,
})
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isVaultEntry(value: unknown): value is VaultEntry {
if (!isRecord(value)) return false
return typeof value.path === 'string'
&& typeof value.filename === 'string'
&& typeof value.title === 'string'
&& Array.isArray(value.aliases)
}
function isNoteListItem(value: unknown): value is NoteListItem {
if (!isRecord(value)) return false
return typeof value.path === 'string'
&& typeof value.title === 'string'
&& typeof value.type === 'string'
}
function sanitizeEntries(value: unknown): VaultEntry[] | undefined {
if (!Array.isArray(value)) return undefined
const entries = value.filter(isVaultEntry)
return entries.length > 0 ? entries : undefined
}
function sanitizeNoteList(value: unknown): NoteListItem[] | undefined {
if (!Array.isArray(value)) return undefined
const noteList = value.filter(isNoteListItem)
return noteList.length > 0 ? noteList : undefined
}
function sanitizeContext(value: unknown): AiWorkspaceWindowSharedContext {
if (!isRecord(value)) return EMPTY_CONTEXT
const activeEntry = value.activeEntry === null || isVaultEntry(value.activeEntry)
? value.activeEntry
: undefined
const noteListFilter = isRecord(value.noteListFilter)
? {
type: typeof value.noteListFilter.type === 'string' ? value.noteListFilter.type : null,
query: typeof value.noteListFilter.query === 'string' ? value.noteListFilter.query : '',
}
: undefined
return {
activeConversationId: typeof value.activeConversationId === 'string' ? value.activeConversationId : undefined,
activeEntry,
activeNoteContent: typeof value.activeNoteContent === 'string' ? value.activeNoteContent : null,
entries: sanitizeEntries(value.entries),
noteList: sanitizeNoteList(value.noteList),
noteListFilter,
openTabs: sanitizeEntries(value.openTabs),
vaultPath: typeof value.vaultPath === 'string' ? value.vaultPath : undefined,
vaultPaths: Array.isArray(value.vaultPaths)
? value.vaultPaths.filter((item): item is string => typeof item === 'string')
: undefined,
}
}
function cloneEntryForWindowContext(entry: VaultEntry): VaultEntry {
return {
...entry,
createdAt: entry.createdAt ?? null,
modifiedAt: entry.modifiedAt ?? null,
aliases: [...entry.aliases],
belongsTo: [...entry.belongsTo],
relatedTo: [...entry.relatedTo],
outgoingLinks: [...entry.outgoingLinks],
listPropertiesDisplay: [...entry.listPropertiesDisplay],
properties: { ...entry.properties },
relationships: Object.fromEntries(
Object.entries(entry.relationships).map(([key, values]) => [key, [...values]]),
),
}
}
function cloneContextForWindow(nextContext: AiWorkspaceWindowSharedContext): AiWorkspaceWindowSharedContext {
return {
...nextContext,
activeEntry: nextContext.activeEntry ? cloneEntryForWindowContext(nextContext.activeEntry) : nextContext.activeEntry,
entries: nextContext.entries?.map(cloneEntryForWindowContext),
openTabs: nextContext.openTabs?.map(cloneEntryForWindowContext),
noteList: nextContext.noteList?.map((item) => ({ ...item })),
noteListFilter: nextContext.noteListFilter ? { ...nextContext.noteListFilter } : nextContext.noteListFilter,
vaultPaths: nextContext.vaultPaths ? [...nextContext.vaultPaths] : nextContext.vaultPaths,
}
}
export function aiWorkspaceWindowSharedContextSnapshot(): AiWorkspaceWindowSharedContext {
return contextStore.getSnapshot()
}
export function subscribeAiWorkspaceWindowSharedContext(listener: () => void): () => void {
return contextStore.subscribe(listener)
}
export function publishAiWorkspaceWindowSharedContext(nextContext: AiWorkspaceWindowSharedContext): void {
contextStore.publishSnapshot(cloneContextForWindow(nextContext))
}
contextStore.ensureCrossWindowSync()

View file

@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { syncAppIconThemeMode } from './appIconTheme'
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
isTauri: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: mocks.invoke,
}))
vi.mock('../mock-tauri', () => ({
isTauri: mocks.isTauri,
}))
describe('syncAppIconThemeMode', () => {
beforeEach(() => {
mocks.invoke.mockReset()
mocks.isTauri.mockReset()
})
it('skips browser runs', async () => {
mocks.isTauri.mockReturnValue(false)
await syncAppIconThemeMode('dark')
expect(mocks.invoke).not.toHaveBeenCalled()
})
it('sends the resolved theme mode to Tauri', async () => {
mocks.isTauri.mockReturnValue(true)
mocks.invoke.mockResolvedValue(undefined)
await syncAppIconThemeMode('dark')
expect(mocks.invoke).toHaveBeenCalledWith('update_app_icon', { themeMode: 'dark' })
})
})

View file

@ -0,0 +1,13 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import type { ResolvedThemeMode } from './themeMode'
export async function syncAppIconThemeMode(themeMode: ResolvedThemeMode): Promise<void> {
if (!isTauri()) return
try {
await invoke('update_app_icon', { themeMode })
} catch (error) {
console.warn('Failed to update app icon for theme mode', error)
}
}

View file

@ -0,0 +1,55 @@
import { Channel, invoke } from '@tauri-apps/api/core'
import { normalizeReleaseChannel } from './releaseChannel'
export interface AppUpdateMetadata {
currentVersion: string
version: string
date?: string
body?: string
}
export type AppUpdateDownloadEvent =
| { event: 'Started'; data: { contentLength?: number } }
| { event: 'Progress'; data: { chunkLength: number } }
| { event: 'Finished' }
export const RESTART_REQUIRED_FOLDER_PICKER_MESSAGE =
'HoloLake Era needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.'
let restartRequiredAfterUpdate = false
export function markRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = true
}
export function clearRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = false
}
export function isRestartRequiredAfterUpdate(): boolean {
return restartRequiredAfterUpdate
}
export async function checkForAppUpdate(
releaseChannel: string | null | undefined,
): Promise<AppUpdateMetadata | null> {
return invoke<AppUpdateMetadata | null>('check_for_app_update', {
releaseChannel: normalizeReleaseChannel(releaseChannel),
})
}
export async function downloadAndInstallAppUpdate(
releaseChannel: string | null | undefined,
expectedVersion: string,
onEvent: (event: AppUpdateDownloadEvent) => void,
): Promise<void> {
const channel = new Channel<AppUpdateDownloadEvent>()
channel.onmessage = onEvent
await invoke('download_and_install_app_update', {
releaseChannel: normalizeReleaseChannel(releaseChannel),
expectedVersion,
onEvent: channel,
})
markRestartRequiredAfterUpdate()
}

View file

@ -0,0 +1,7 @@
import type { Settings } from '../types'
export function areAutomaticUpdateChecksEnabled(
settings: Pick<Settings, 'automatic_update_checks_enabled'> | null | undefined,
): boolean {
return settings?.automatic_update_checks_enabled !== false
}

View file

@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createCheckListItemBlockSpec } from '../../node_modules/@blocknote/core/src/blocks/ListItem/CheckListItem/block'
const checkListItemSpec = createCheckListItemBlockSpec()
type CheckListItemBlock = Parameters<typeof checkListItemSpec.implementation.render>[0]
type CheckListItemEditor = Parameters<typeof checkListItemSpec.implementation.render>[1]
type RenderedCheckListItem = ReturnType<typeof checkListItemSpec.implementation.render>
type CheckListItemControlEditor = {
getBlock: (id: string) => CheckListItemBlock | undefined
updateBlock: (block: CheckListItemBlock, update: { props: { checked: boolean } }) => void
}
type CheckListItemLookup = CheckListItemControlEditor['getBlock']
function createCheckListItem(checked = false): CheckListItemBlock {
return {
id: 'check-list-item-1',
type: 'checkListItem',
props: { checked },
content: [],
children: [],
} as CheckListItemBlock
}
function createEditor(getBlock: CheckListItemLookup): CheckListItemControlEditor {
return {
getBlock: vi.fn(getBlock),
updateBlock: vi.fn(),
}
}
function renderCheckListItem(editor: CheckListItemControlEditor, checked = false) {
const block = createCheckListItem(checked)
const view = checkListItemSpec.implementation.render(
block,
editor as CheckListItemEditor,
) as RenderedCheckListItem
const host = document.createElement('div')
host.appendChild(view.dom)
document.body.appendChild(host)
const checkbox = host.querySelector('input[type="checkbox"]')
if (!(checkbox instanceof HTMLInputElement)) throw new Error('Expected checklist checkbox')
return { block, checkbox, host, view }
}
function dispatchChange(checkbox: HTMLInputElement) {
checkbox.dispatchEvent(new window.Event('change'))
}
function expectCheckboxChangeIgnored(getBlock: CheckListItemLookup) {
const editor = createEditor(getBlock)
const { block, checkbox, view } = renderCheckListItem(editor)
checkbox.checked = true
expect(() => dispatchChange(checkbox)).not.toThrow()
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
expect(editor.updateBlock).not.toHaveBeenCalled()
view.destroy?.()
}
afterEach(() => {
document.body.replaceChildren()
})
describe('patched BlockNote checklist controls', () => {
const staleLookupCases: Array<[string, CheckListItemLookup]> = [
['when the target checklist block disappeared', () => undefined],
['when BlockNote throws during block lookup', () => {
throw new Error('Block with ID check-list-item-1 not found')
}],
]
it.each(staleLookupCases)('ignores stale checkbox changes %s', (_name, getBlock) => {
expectCheckboxChangeIgnored(getBlock)
})
it('applies live checkbox changes to the current checklist block', () => {
const existingBlock = createCheckListItem()
const editor = createEditor(() => existingBlock)
const { block, checkbox, view } = renderCheckListItem(editor)
checkbox.checked = true
dispatchChange(checkbox)
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
expect(editor.updateBlock).toHaveBeenCalledWith(existingBlock, {
props: { checked: true },
})
view.destroy?.()
})
})

View file

@ -0,0 +1,105 @@
import { createCodeBlockSpec } from '@blocknote/core'
import { codeBlockOptions } from '@blocknote/code-block'
import { afterEach, describe, expect, it, vi } from 'vitest'
const codeBlockSpec = createCodeBlockSpec({
...codeBlockOptions,
defaultLanguage: 'text',
supportedLanguages: {
text: { name: 'Plain Text' },
typescript: { name: 'TypeScript', aliases: ['ts'] },
},
})
type CodeBlock = Parameters<typeof codeBlockSpec.implementation.render>[0]
type CodeBlockEditor = Parameters<typeof codeBlockSpec.implementation.render>[1]
type RenderedCodeBlock = ReturnType<typeof codeBlockSpec.implementation.render>
type CodeBlockControlEditor = {
isEditable: boolean
getBlock: (id: string) => CodeBlock | undefined
updateBlock: (id: string, update: { props: { language: string } }) => void
}
type CodeBlockLookup = CodeBlockControlEditor['getBlock']
function createCodeBlock(): CodeBlock {
return {
id: 'code-block-1',
type: 'codeBlock',
props: { language: 'text' },
content: [],
children: [],
} as CodeBlock
}
function createEditor(getBlock: CodeBlockLookup): CodeBlockControlEditor {
return {
isEditable: true,
getBlock: vi.fn(getBlock),
updateBlock: vi.fn(),
}
}
function renderLanguageSelect(editor: CodeBlockControlEditor) {
const block = createCodeBlock()
const view = codeBlockSpec.implementation.render(
block,
editor as CodeBlockEditor,
) as RenderedCodeBlock
const host = document.createElement('div')
host.appendChild(view.dom)
document.body.appendChild(host)
const select = host.querySelector('select')
if (!select) throw new Error('Expected code block language select')
return { block, host, select, view }
}
function dispatchChange(select: HTMLSelectElement) {
select.dispatchEvent(new window.Event('change'))
}
function expectLanguageChangeIgnored(getBlock: CodeBlockLookup) {
const editor = createEditor(getBlock)
const { block, select, view } = renderLanguageSelect(editor)
select.value = 'typescript'
expect(() => dispatchChange(select)).not.toThrow()
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
expect(editor.updateBlock).not.toHaveBeenCalled()
view.destroy?.()
}
afterEach(() => {
document.body.replaceChildren()
})
describe('patched BlockNote code block controls', () => {
const staleLookupCases: Array<[string, CodeBlockLookup]> = [
['when the target code block disappeared', () => undefined],
['when BlockNote throws during block lookup', () => {
throw new Error('Block with ID code-block-1 not found')
}],
]
it.each(staleLookupCases)('ignores stale language changes %s', (_name, getBlock) => {
expectLanguageChangeIgnored(getBlock)
})
it('keeps live language changes wired to the code block update', () => {
const existingBlock = createCodeBlock()
const editor = createEditor(() => existingBlock)
const { block, select, view } = renderLanguageSelect(editor)
select.value = 'typescript'
dispatchChange(select)
expect(editor.getBlock).toHaveBeenCalledWith(block.id)
expect(editor.updateBlock).toHaveBeenCalledWith(block.id, {
props: { language: 'typescript' },
})
view.destroy?.()
})
})

View file

@ -0,0 +1,58 @@
import { BlockNoteEditor } from '@blocknote/core'
import { afterEach, describe, expect, it } from 'vitest'
const arrayToReversedDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed')
function removeArrayToReversed() {
Object.defineProperty(Array.prototype, 'toReversed', {
configurable: true,
writable: true,
value: undefined,
})
}
function restoreArrayToReversed() {
if (arrayToReversedDescriptor) {
Object.defineProperty(Array.prototype, 'toReversed', arrayToReversedDescriptor)
return
}
delete Array.prototype.toReversed
}
afterEach(() => {
restoreArrayToReversed()
})
describe('patched BlockNote rich text copy compatibility', () => {
it('serializes marked rich text without Array.prototype.toReversed', () => {
removeArrayToReversed()
const editor = BlockNoteEditor.create({
initialContent: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Copied rich text',
styles: { bold: true, italic: true },
},
],
},
],
})
try {
const html = editor.blocksToHTMLLossy(editor.document)
const fullHtml = editor.blocksToFullHTML(editor.document)
const markdown = editor.blocksToMarkdownLossy(editor.document)
expect(html).toContain('Copied rich text')
expect(fullHtml).toContain('Copied rich text')
expect(markdown).toContain('Copied rich text')
} finally {
editor._tiptapEditor.destroy()
}
})
})

View file

@ -0,0 +1,26 @@
import { BlockNoteEditor } from '@blocknote/core'
import { describe, expect, it } from 'vitest'
import { schema } from '../components/editorSchema'
type TiptapExtension = {
name: string
options?: {
openOnClick?: unknown
}
}
function findLinkExtension(editor: BlockNoteEditor<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>) {
const extensions = editor._tiptapEditor.extensionManager.extensions as TiptapExtension[]
return extensions.find((extension) => extension.name === 'link')
}
describe('patched BlockNote link click handling', () => {
it('disables Tiptap direct window.open handling for editor links', () => {
const editor = BlockNoteEditor.create({ schema })
const linkExtension = findLinkExtension(editor)
expect(linkExtension?.options?.openOnClick).toBe(false)
editor._tiptapEditor.destroy()
})
})

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { getMountedBoundingClientRectCache } from '@blocknote/react'
describe('patched BlockNote popover references', () => {
it('uses the virtual rect when a remounting suggestion menu has no DOM element', () => {
const fallbackRect = new DOMRect(4, 8, 16, 24)
const readRect = getMountedBoundingClientRectCache({
element: undefined,
getBoundingClientRect: () => fallbackRect,
} as never)
expect(() => readRect()).not.toThrow()
expect(readRect()).toBe(fallbackRect)
})
})

View file

@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SideMenuView } from '../../node_modules/@blocknote/core/src/extensions/SideMenu/SideMenu'
const originalElementsFromPoint = document.elementsFromPoint
function createRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }) {
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect
}
function createStaleEditorDom() {
const editor = document.createElement('div')
editor.className = 'bn-editor'
editor.getBoundingClientRect = vi.fn(() => createRect({ left: 0, top: 0, width: 480, height: 240 }))
const blockGroup = document.createElement('div')
blockGroup.className = 'bn-block-group'
blockGroup.getBoundingClientRect = vi.fn(() => createRect({ left: 20, top: 20, width: 360, height: 160 }))
const block = document.createElement('div')
block.setAttribute('data-node-type', 'blockContainer')
block.setAttribute('data-id', 'stale-table-block')
block.getBoundingClientRect = vi.fn(() => createRect({ left: 20, top: 40, width: 260, height: 48 }))
blockGroup.appendChild(block)
editor.appendChild(blockGroup)
document.body.appendChild(editor)
return { block, editor }
}
describe('patched BlockNote side menu lifecycle', () => {
afterEach(() => {
document.elementsFromPoint = originalElementsFromPoint
document.body.innerHTML = ''
})
it('does not publish side-menu state for a stale hovered block id', () => {
const { block, editor } = createStaleEditorDom()
document.elementsFromPoint = vi.fn(() => [block, editor])
const updates: unknown[] = []
const view = new SideMenuView(
{
getBlock: vi.fn(() => undefined),
isEditable: true,
} as never,
{
dom: editor,
root: document,
} as never,
(state) => updates.push(state),
)
try {
view.onMouseMove(new MouseEvent('mousemove', { clientX: 40, clientY: 50 }))
expect(updates).toEqual([])
} finally {
view.destroy()
}
})
})

View file

@ -0,0 +1,105 @@
import { SuggestionMenu } from '@blocknote/core/extensions'
import { describe, expect, it, vi } from 'vitest'
type SuggestionPluginState = {
triggerCharacter: string
deleteTriggerCharacter: boolean
queryStartPos: () => number
query: string
decorationId: string
ignoreQueryLength?: boolean
}
type SuggestionEditorState = Record<string, SuggestionPluginState | undefined>
type SuggestionRoot = {
addEventListener: (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => void
removeEventListener: (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
) => void
querySelector: (selectors: string) => Element | null
}
type SuggestionEditorView = {
root: SuggestionRoot
state: SuggestionEditorState
}
type SuggestionPluginView = {
emitUpdate: (triggerCharacter: string) => void
update: (view: SuggestionEditorView, prevState: SuggestionEditorState) => void
}
type SuggestionMenuPlugin = {
spec: {
key: { key: string }
view: (view: SuggestionEditorView) => SuggestionPluginView
}
}
function createSuggestionPlugin() {
const extensionFactory = SuggestionMenu() as unknown as (context: {
editor: { isEditable: boolean }
}) => { prosemirrorPlugins: SuggestionMenuPlugin[] }
return extensionFactory({ editor: { isEditable: true } }).prosemirrorPlugins[0]
}
function createState(
plugin: SuggestionMenuPlugin,
pluginState?: SuggestionPluginState,
): SuggestionEditorState {
return {
[plugin.spec.key.key]: pluginState,
}
}
function createPluginState(): SuggestionPluginState {
return {
triggerCharacter: '/',
deleteTriggerCharacter: true,
queryStartPos: () => 1,
query: '',
decorationId: 'missing-decoration',
}
}
function createEditorView(state: SuggestionEditorState): SuggestionEditorView {
return {
root: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
querySelector: vi.fn(() => null),
},
state,
}
}
describe('patched BlockNote suggestion menu lifecycle', () => {
it('ignores late updates before the suggestion menu state initializes', () => {
const plugin = createSuggestionPlugin()
const editorView = createEditorView(createState(plugin))
const pluginView = plugin.spec.view(editorView)
expect(() => pluginView.emitUpdate('/')).not.toThrow()
})
it('closes a suggestion menu before its decoration mounts without throwing', () => {
const plugin = createSuggestionPlugin()
const inactiveState = createState(plugin)
const activeState = createState(plugin, createPluginState())
const editorView = createEditorView(activeState)
const pluginView = plugin.spec.view(editorView)
expect(() => pluginView.update(editorView, inactiveState)).not.toThrow()
editorView.state = inactiveState
expect(() => pluginView.update(editorView, activeState)).not.toThrow()
})
})

View file

@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { BlockNoteContext, SuggestionMenuWrapper } from '@blocknote/react'
import { describe, expect, it, vi } from 'vitest'
import type { SuggestionMenuProps } from '@blocknote/react'
function createEditor() {
return {
domElement: document.createElement('div'),
}
}
function Menu({ items, onItemClick }: SuggestionMenuProps<string>) {
return (
<button type="button" onClick={() => onItemClick?.(items[0])}>
Pick suggestion
</button>
)
}
describe('patched BlockNote suggestion wrapper', () => {
it('ignores stale query cleanup before running a suggestion item action', async () => {
const closeMenu = vi.fn()
const clearQuery = vi.fn(() => {
throw new RangeError('Position -1322 outside of fragment')
})
const onItemClick = vi.fn()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
render(
<BlockNoteContext.Provider
value={{
editor: createEditor() as never,
setContentEditableProps: vi.fn(),
}}
>
<SuggestionMenuWrapper
query=""
closeMenu={closeMenu}
clearQuery={clearQuery}
getItems={async () => ['paragraph']}
suggestionMenuComponent={Menu}
onItemClick={onItemClick}
/>
</BlockNoteContext.Provider>,
)
fireEvent.click(await screen.findByRole('button', { name: 'Pick suggestion' }))
expect(closeMenu).toHaveBeenCalledOnce()
expect(clearQuery).toHaveBeenCalledOnce()
expect(onItemClick).not.toHaveBeenCalled()
await waitFor(() => {
expect(warn).toHaveBeenCalledWith(
'Ignored stale suggestion menu query cleanup:',
expect.any(RangeError),
)
})
warn.mockRestore()
})
})

View file

@ -0,0 +1,372 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TableHandlesExtension,
TableHandlesView,
} from '@blocknote/core/extensions'
function createTableBlock() {
return {
id: 'table-block',
type: 'table',
content: {
type: 'tableContent',
rows: [
{ cells: ['Head 1', 'Head 2'] },
{ cells: ['A', 'B'] },
],
},
}
}
function createRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }) {
return {
bottom: top + height,
height,
left,
right: left + width,
toJSON: () => ({}),
top,
width,
x: left,
y: top,
} as DOMRect
}
function createTableDom(blockId = 'stale-table-block') {
const editorRoot = document.createElement('div')
const blockContainer = document.createElement('div')
const tableWrapper = document.createElement('div')
const table = document.createElement('table')
const tbody = document.createElement('tbody')
const row = document.createElement('tr')
const cell = document.createElement('td')
blockContainer.setAttribute('data-node-type', 'blockContainer')
blockContainer.setAttribute('data-id', blockId)
tableWrapper.className = 'tableWrapper'
tableWrapper.getBoundingClientRect = vi.fn(() => createRect({ left: 10, top: 10, width: 180, height: 80 }))
tbody.getBoundingClientRect = vi.fn(() => createRect({ left: 10, top: 10, width: 180, height: 80 }))
row.appendChild(cell)
tbody.appendChild(row)
table.appendChild(tbody)
tableWrapper.appendChild(table)
blockContainer.appendChild(tableWrapper)
editorRoot.appendChild(blockContainer)
document.body.appendChild(editorRoot)
return { cell, editorRoot }
}
function createSelectionStateThatRejectsNaNPositions() {
const selectionTransaction = {
setSelection: vi.fn(),
}
const resolvedPosition = {
posAtIndex: vi.fn((index: number) => index),
}
return {
doc: {
resolve: vi.fn((position: number) => {
if (!Number.isFinite(position)) {
throw new Error(`Position ${position} out of range`)
}
return resolvedPosition
}),
},
tr: selectionTransaction,
apply: vi.fn(),
}
}
function mountTableHandlesExtension() {
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const selectionState = createSelectionStateThatRejectsNaNPositions()
const dispatch = vi.fn()
const editor = {
headless: true,
isEditable: true,
prosemirrorView: {
root: document,
},
exec: vi.fn((command: (state: never, dispatch: never) => unknown) =>
command(selectionState as never, dispatch as never),
),
transact: vi.fn(),
}
const extension = TableHandlesExtension()({ editor: editor as never })
const plugin = extension.prosemirrorPlugins?.[0]
if (!plugin?.spec.view) {
throw new Error('TableHandlesExtension did not register a plugin view')
}
const view = plugin.spec.view({
dom: editorRoot,
root: document,
} as never) as TableHandlesView
return { editor, extension, view, selectionState }
}
function showTableHandles(view: TableHandlesView) {
view.state = {
block: createTableBlock(),
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
draggingState: undefined,
} as never
}
function expectAddRowAndColumnActionsToStaySafe(
extension: ReturnType<typeof mountTableHandlesExtension>['extension'],
index: number,
) {
expect(() =>
extension.addRowOrColumn(index, { orientation: 'row', side: 'below' }),
).not.toThrow()
expect(() =>
extension.addRowOrColumn(index, { orientation: 'column', side: 'right' }),
).not.toThrow()
}
describe('BlockNote table handles regression', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('hides stale table handles instead of throwing when tbody is missing during update', () => {
const block = createTableBlock()
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const editor = {
getBlock: vi.fn(() => block),
}
const emitUpdate = vi.fn()
const view = new TableHandlesView(
editor as never,
{
dom: editorRoot,
root: document,
} as never,
emitUpdate,
)
view.state = {
block,
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
} as never
const staleTableWrapper = document.createElement('div')
editorRoot.appendChild(staleTableWrapper)
view.tableElement = staleTableWrapper
expect(() => view.update()).not.toThrow()
expect(view.state?.show).toBe(false)
expect(view.state?.showAddOrRemoveRowsButton).toBe(false)
expect(view.state?.showAddOrRemoveColumnsButton).toBe(false)
expect(emitUpdate).toHaveBeenCalled()
view.destroy()
})
it('hides stale table handles instead of throwing when a reload clears the hovered block', () => {
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const editor = {
getBlock: vi.fn(),
}
const emitUpdate = vi.fn()
const view = new TableHandlesView(
editor as never,
{
dom: editorRoot,
root: document,
} as never,
emitUpdate,
)
view.state = {
block: undefined,
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
draggingState: {
draggedCellOrientation: 'row',
originalIndex: 0,
mousePos: 10,
},
} as never
expect(() => view.update()).not.toThrow()
expect(editor.getBlock).not.toHaveBeenCalled()
expect(view.state?.show).toBe(false)
expect(view.state?.showAddOrRemoveRowsButton).toBe(false)
expect(view.state?.showAddOrRemoveColumnsButton).toBe(false)
expect(view.state?.rowIndex).toBeUndefined()
expect(view.state?.colIndex).toBeUndefined()
expect(view.state?.draggingState).toBeUndefined()
expect(emitUpdate).toHaveBeenCalled()
view.destroy()
})
it('ignores stale table drag starts instead of throwing when hover state is unavailable', () => {
const { editor, extension, view } = mountTableHandlesExtension()
expect(() =>
extension.colDragStart({ dataTransfer: null, clientX: 10 }),
).not.toThrow()
expect(() =>
extension.rowDragStart({ dataTransfer: null, clientY: 10 }),
).not.toThrow()
expect(editor.transact).not.toHaveBeenCalled()
view.destroy()
})
it('ignores stale table drag end events instead of throwing after state disappears', () => {
const { extension, view } = mountTableHandlesExtension()
expect(() => extension.dragEnd()).not.toThrow()
view.destroy()
})
it('ignores add row or column actions when the selection target is stale', () => {
const { editor, extension, view } = mountTableHandlesExtension()
showTableHandles(view)
view.tablePos = undefined
expectAddRowAndColumnActionsToStaySafe(extension, 0)
expect(editor.exec).not.toHaveBeenCalled()
view.tablePos = 0
expectAddRowAndColumnActionsToStaySafe(extension, Number.NaN)
expect(editor.exec).not.toHaveBeenCalled()
view.destroy()
})
it('hides table handles instead of throwing when hover lookup sees a stale block id', () => {
const block = createTableBlock()
const { cell, editorRoot } = createTableDom(block.id)
const emitUpdate = vi.fn()
const editor = {
isEditable: true,
transact: vi.fn(() => {
throw new Error(`Block with ID ${block.id} not found`)
}),
}
const view = new TableHandlesView(
editor as never,
{
dom: editorRoot,
root: document,
} as never,
emitUpdate,
)
view.state = {
block,
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
draggingState: undefined,
} as never
const event = new MouseEvent('mousemove', {
bubbles: true,
clientX: 20,
clientY: 20,
})
Object.defineProperty(event, 'target', { value: cell })
expect(() => view.mouseMoveHandler(event)).not.toThrow()
expect(editor.transact).toHaveBeenCalled()
expect(view.state?.show).toBe(false)
expect(view.state?.showAddOrRemoveRowsButton).toBe(false)
expect(view.state?.showAddOrRemoveColumnsButton).toBe(false)
expect(emitUpdate).toHaveBeenCalled()
view.destroy()
})
it('cancels stale table drops instead of throwing when no hovered row or column is available', () => {
const block = createTableBlock()
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const editor = {
getBlock: vi.fn(() => block),
}
const emitUpdate = vi.fn()
const view = new TableHandlesView(
editor as never,
{
dom: editorRoot,
root: document,
} as never,
emitUpdate,
)
view.state = {
block,
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
referencePosTable: {
left: 0,
top: 0,
right: 100,
bottom: 100,
},
rowIndex: undefined,
colIndex: undefined,
draggingState: {
draggedCellOrientation: 'row',
originalIndex: 0,
mousePos: 10,
},
widgetContainer: undefined,
} as never
const dropEvent = {
preventDefault: vi.fn(),
}
expect(() => view.dropHandler(dropEvent as never)).not.toThrow()
expect(dropEvent.preventDefault).toHaveBeenCalled()
expect(view.state?.draggingState).toBeUndefined()
expect(view.state?.show).toBe(false)
expect(view.state?.rowIndex).toBeUndefined()
expect(view.state?.colIndex).toBeUndefined()
expect(emitUpdate).toHaveBeenCalled()
view.destroy()
})
})

View file

@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createCrossWindowPersistedStore } from './crossWindowPersistedStore'
const STORAGE_KEY = 'tolaria:test-cross-window-store'
const CHANNEL_NAME = 'tolaria-test-cross-window-store'
interface TestSnapshot {
value: string
}
class LocalStorageMock implements Storage {
private readonly store = new Map<string, string>()
get length() {
return this.store.size
}
clear() {
this.store.clear()
}
getItem(key: string) {
return this.store.get(key) ?? null
}
key(index: number) {
return Array.from(this.store.keys())[index] ?? null
}
removeItem(key: string) {
this.store.delete(key)
}
setItem(key: string, value: string) {
this.store.set(key, value)
}
}
class BroadcastChannelMock {
static channels: BroadcastChannelMock[] = []
onmessage: ((event: MessageEvent<unknown>) => void) | null = null
constructor(readonly name: string) {
BroadcastChannelMock.channels.push(this)
}
postMessage(message: unknown) {
for (const channel of BroadcastChannelMock.channels) {
if (channel === this || channel.name !== this.name) continue
channel.onmessage?.(new MessageEvent('message', { data: message }))
}
}
}
function createTestStore() {
return createCrossWindowPersistedStore<TestSnapshot>({
broadcastChannelName: CHANNEL_NAME,
broadcastMessage: { type: 'test-updated' },
emptySnapshot: { value: '' },
sanitizeStoredValue: (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return { value: '' }
const candidate = value as Partial<TestSnapshot>
return typeof candidate.value === 'string' ? { value: candidate.value } : { value: '' }
},
storageKey: STORAGE_KEY,
})
}
describe('createCrossWindowPersistedStore', () => {
beforeEach(() => {
BroadcastChannelMock.channels = []
vi.stubGlobal('localStorage', new LocalStorageMock())
vi.stubGlobal('BroadcastChannel', BroadcastChannelMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('hydrates and sanitizes persisted snapshots', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'persisted', ignored: true }))
const store = createTestStore()
expect(store.getSnapshot()).toEqual({ value: 'persisted' })
})
it('syncs from storage events and notifies subscribers', () => {
const store = createTestStore()
const listener = vi.fn()
store.subscribe(listener)
store.ensureCrossWindowSync()
localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'external' }))
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY }))
expect(store.getSnapshot()).toEqual({ value: 'external' })
expect(listener).toHaveBeenCalledTimes(1)
})
it('broadcasts local publishes to sibling stores through localStorage', () => {
const firstStore = createTestStore()
const secondStore = createTestStore()
const secondListener = vi.fn()
firstStore.ensureCrossWindowSync()
secondStore.ensureCrossWindowSync()
secondStore.subscribe(secondListener)
firstStore.publishSnapshot({ value: 'published' })
expect(secondStore.getSnapshot()).toEqual({ value: 'published' })
expect(secondListener).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,91 @@
export type CrossWindowStoreReadReason = 'initial' | 'storage'
type Listener = () => void
interface CrossWindowPersistedStoreOptions<TSnapshot> {
broadcastChannelName: string
broadcastMessage: unknown
emptySnapshot: TSnapshot
sanitizeStoredValue: (value: unknown, reason: CrossWindowStoreReadReason) => TSnapshot
storageKey: string
}
export function createCrossWindowPersistedStore<TSnapshot>({
broadcastChannelName,
broadcastMessage,
emptySnapshot,
sanitizeStoredValue,
storageKey,
}: CrossWindowPersistedStoreOptions<TSnapshot>) {
let snapshot = readStoredSnapshot('initial')
let broadcastChannel: BroadcastChannel | null = null
const listeners = new Set<Listener>()
function readStoredSnapshot(reason: CrossWindowStoreReadReason = 'initial'): TSnapshot {
if (typeof localStorage === 'undefined') return emptySnapshot
try {
return sanitizeStoredValue(JSON.parse(localStorage.getItem(storageKey) ?? '{}'), reason)
} catch {
return emptySnapshot
}
}
function writeStoredSnapshot(nextSnapshot = snapshot): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(storageKey, JSON.stringify(nextSnapshot))
} catch {
// Cross-window localStorage is a best-effort cache; callers may have a durable backend.
}
}
function notifyListeners(): void {
for (const listener of listeners) listener()
}
function broadcastSnapshot(): void {
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(broadcastChannelName)
broadcastChannel.postMessage(broadcastMessage)
}
function replaceSnapshot(nextSnapshot: TSnapshot): void {
snapshot = nextSnapshot
notifyListeners()
}
function publishSnapshot(nextSnapshot: TSnapshot): void {
snapshot = nextSnapshot
writeStoredSnapshot()
broadcastSnapshot()
notifyListeners()
}
function syncFromStorage(): void {
replaceSnapshot(readStoredSnapshot('storage'))
}
function ensureCrossWindowSync(): void {
if (typeof window === 'undefined') return
window.addEventListener('storage', (event) => {
if (event.key === storageKey) syncFromStorage()
})
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(broadcastChannelName)
broadcastChannel.onmessage = syncFromStorage
}
return {
ensureCrossWindowSync,
getSnapshot: () => snapshot,
publishSnapshot,
replaceSnapshot,
subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
},
writeStoredSnapshot,
}
}

View file

@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
__resetFeedbackDiagnosticsForTest,
buildSanitizedDiagnosticBundle,
startFeedbackDiagnosticsCapture,
} from './feedbackDiagnostics'
describe('feedbackDiagnostics', () => {
beforeEach(() => {
__resetFeedbackDiagnosticsForTest()
})
it('sanitizes recent warnings and errors before building the bundle', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const stopCapture = startFeedbackDiagnosticsCapture()
const sampleToken = ['ghp', 'super-secret-token'].join('_')
console.error(`Load failed for /Users/luca/Laputa/private.md with token ${sampleToken}`)
console.warn('Retrying from C:\\Users\\luca\\Notes\\vault.md')
const bundle = buildSanitizedDiagnosticBundle({
buildNumber: 'b281',
releaseChannel: 'alpha',
})
expect(bundle).toContain('HoloLake Era sanitized diagnostics')
expect(bundle).toContain('Build: b281')
expect(bundle).toContain('Release channel: alpha')
expect(bundle).toContain('[error] Load failed for [redacted-path] with token [redacted-token]')
expect(bundle).toContain('[warn] Retrying from [redacted-path]')
expect(bundle).not.toContain('/Users/luca/Laputa/private.md')
expect(bundle).not.toContain(sampleToken)
expect(bundle).not.toContain('C:\\Users\\luca\\Notes\\vault.md')
stopCapture()
errorSpy.mockRestore()
warnSpy.mockRestore()
})
it('explains when no safe diagnostics were available', () => {
const bundle = buildSanitizedDiagnosticBundle({
buildNumber: undefined,
releaseChannel: null,
})
expect(bundle).toContain('No safe recent diagnostics were available.')
})
})

View file

@ -0,0 +1,157 @@
import { isTauri } from '../mock-tauri'
import { TOKEN_REDACTION, isSensitiveDiagnosticKey, sanitizeDiagnosticText } from './sensitiveTextRedaction'
type DiagnosticLevel = 'error' | 'warn'
interface DiagnosticEntry {
level: DiagnosticLevel
message: string
}
interface DiagnosticBundleContext {
buildNumber?: string
releaseChannel?: string | null
}
const MAX_DIAGNOSTICS = 8
let recentDiagnostics: DiagnosticEntry[] = []
let stopCapture: (() => void) | null = null
function truncate(input: string, maxLength = 240): string {
if (input.length <= maxLength) return input
return `${input.slice(0, maxLength - 1)}`
}
function sanitizeText(input: string): string {
return truncate(sanitizeDiagnosticText({ text: input }))
}
function safeSerialize(value: unknown): string {
if (value instanceof Error) {
return `${value.name}: ${value.message}`
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (value == null) {
return String(value)
}
try {
return JSON.stringify(value, (key, nestedValue) => {
if (typeof nestedValue === 'string') {
return isSensitiveDiagnosticKey({ text: key }) ? TOKEN_REDACTION : truncate(nestedValue, 120)
}
return nestedValue
})
} catch {
return Object.prototype.toString.call(value)
}
}
function recordDiagnostic(level: DiagnosticLevel, values: unknown[]): void {
const message = sanitizeText(values.map((value) => safeSerialize(value)).join(' '))
if (!message) return
recentDiagnostics = [
...recentDiagnostics.slice(-(MAX_DIAGNOSTICS - 1)),
{ level, message },
]
}
function resolveRuntime(): string {
if (typeof window === 'undefined') return 'unknown'
return isTauri() ? 'tauri' : 'browser'
}
function resolvePlatform(): string {
if (typeof navigator === 'undefined') return 'unknown'
return sanitizeText(navigator.platform || 'unknown')
}
function resolveUserAgent(): string {
if (typeof navigator === 'undefined') return 'unknown'
return sanitizeText(navigator.userAgent || 'unknown')
}
export function startFeedbackDiagnosticsCapture(): () => void {
if (stopCapture || typeof window === 'undefined') {
return stopCapture ?? (() => {})
}
const originalError = console.error
const originalWarn = console.warn
console.error = (...args: Parameters<typeof console.error>) => {
recordDiagnostic('error', args)
originalError(...args)
}
console.warn = (...args: Parameters<typeof console.warn>) => {
recordDiagnostic('warn', args)
originalWarn(...args)
}
const handleError = (event: ErrorEvent) => {
recordDiagnostic('error', [event.error ?? event.message ?? 'Unhandled window error'])
}
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
recordDiagnostic('error', ['Unhandled rejection:', event.reason])
}
window.addEventListener('error', handleError)
window.addEventListener('unhandledrejection', handleUnhandledRejection)
stopCapture = () => {
console.error = originalError
console.warn = originalWarn
window.removeEventListener('error', handleError)
window.removeEventListener('unhandledrejection', handleUnhandledRejection)
stopCapture = null
}
return stopCapture
}
export function buildSanitizedDiagnosticBundle({
buildNumber,
releaseChannel,
}: DiagnosticBundleContext): string {
const lines = [
'HoloLake Era sanitized diagnostics',
`Generated: ${new Date().toISOString()}`,
`Build: ${buildNumber ?? 'unknown'}`,
`Release channel: ${releaseChannel ?? 'stable'}`,
`Runtime: ${resolveRuntime()}`,
`Platform: ${resolvePlatform()}`,
`User agent: ${resolveUserAgent()}`,
'',
'Recent diagnostics:',
]
if (recentDiagnostics.length === 0) {
lines.push('No safe recent diagnostics were available.')
} else {
for (const entry of recentDiagnostics) {
lines.push(`- [${entry.level}] ${entry.message}`)
}
}
lines.push('')
lines.push('Notes: paths and token-like strings are redacted. This bundle is sanitized and optional.')
return lines.join('\n')
}
export function __resetFeedbackDiagnosticsForTest(): void {
stopCapture?.()
recentDiagnostics = []
}

View file

@ -0,0 +1,30 @@
interface FeedbackDialogOpener {
element: HTMLElement | null
reopenCommandPalette: boolean
}
const EMPTY_OPENER: FeedbackDialogOpener = {
element: null,
reopenCommandPalette: false,
}
let pendingOpener: FeedbackDialogOpener = EMPTY_OPENER
function isCommandPaletteInput(element: Element | null): boolean {
return element instanceof Element
&& element.tagName === 'INPUT'
&& element.getAttribute('placeholder') === 'Type a command...'
}
export function rememberFeedbackDialogOpener(element: HTMLElement | null): void {
pendingOpener = {
element,
reopenCommandPalette: isCommandPaletteInput(element),
}
}
export function takeFeedbackDialogOpener(): FeedbackDialogOpener {
const opener = pendingOpener
pendingOpener = EMPTY_OPENER
return opener
}

View file

@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import {
FIFTH_DOMAIN_DISCOVERY_URL,
fetchFifthDomainDiscovery,
parseFifthDomainDiscovery,
} from './fifthDomainDiscovery'
const validDiscovery = {
schema: 'guanghu.ai-discovery/v1',
name: '光湖语言世界 · 第五域',
canonical_repository: 'https://guanghulab.com/fifth-domain/bingshuo/fifth-domain',
repository_map: 'https://guanghulab.com/api/ai/v1/repositories',
server_node_map: 'https://guanghulab.com/api/ai/v1/nodes',
search_api: 'https://guanghulab.com/api/ai/v1/search?q={query}',
resolve_api: 'https://guanghulab.com/api/ai/v1/resolve?id={NUMBER}',
access: 'public-read-only',
}
describe('Fifth Domain discovery', () => {
it('accepts the canonical public read-only discovery document', () => {
expect(parseFifthDomainDiscovery(validDiscovery)).toMatchObject({
access: 'public-read-only',
canonicalRepository: validDiscovery.canonical_repository,
nodeMap: validDiscovery.server_node_map,
})
})
it('rejects a document that claims write access', () => {
expect(() => parseFifthDomainDiscovery({ ...validDiscovery, access: 'write' }))
.toThrow('public-read-only')
})
it('loads the well-known endpoint without sending credentials', async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(validDiscovery) })
await fetchFifthDomainDiscovery(fetcher)
expect(fetcher).toHaveBeenCalledWith(FIFTH_DOMAIN_DISCOVERY_URL, {
cache: 'no-store',
credentials: 'omit',
signal: undefined,
})
})
})

View file

@ -0,0 +1,57 @@
export const FIFTH_DOMAIN_DISCOVERY_URL = 'https://guanghulab.com/.well-known/guanghu.json'
export interface FifthDomainDiscovery {
access: 'public-read-only'
canonicalRepository: string
name: string
nodeMap: string
repositoryMap: string
resolveApi: string
schema: 'guanghu.ai-discovery/v1'
searchApi: string
}
type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Pick<Response, 'ok' | 'json'>>
function requiredString(document: Record<string, unknown>, key: string): string {
const value = document[key]
if (typeof value !== 'string' || !value.trim()) throw new Error(`Fifth Domain discovery is missing ${key}.`)
return value
}
function requiredHttpsUrl(document: Record<string, unknown>, key: string): string {
const value = requiredString(document, key)
if (!value.startsWith('https://guanghulab.com/')) throw new Error(`Fifth Domain discovery ${key} is not a trusted HTTPS route.`)
return value
}
export function parseFifthDomainDiscovery(value: unknown): FifthDomainDiscovery {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Fifth Domain discovery is not an object.')
const document = value as Record<string, unknown>
if (document.schema !== 'guanghu.ai-discovery/v1') throw new Error('Fifth Domain discovery schema is unsupported.')
if (document.access !== 'public-read-only') throw new Error('Fifth Domain connection must remain public-read-only.')
return {
access: 'public-read-only',
canonicalRepository: requiredHttpsUrl(document, 'canonical_repository'),
name: requiredString(document, 'name'),
nodeMap: requiredHttpsUrl(document, 'server_node_map'),
repositoryMap: requiredHttpsUrl(document, 'repository_map'),
resolveApi: requiredHttpsUrl(document, 'resolve_api'),
schema: 'guanghu.ai-discovery/v1',
searchApi: requiredHttpsUrl(document, 'search_api'),
}
}
export async function fetchFifthDomainDiscovery(
fetcher: Fetcher = fetch,
signal?: AbortSignal,
): Promise<FifthDomainDiscovery> {
const response = await fetcher(FIFTH_DOMAIN_DISCOVERY_URL, {
cache: 'no-store',
credentials: 'omit',
signal,
})
if (!response.ok) throw new Error('Fifth Domain public route did not respond successfully.')
return parseFifthDomainDiscovery(await response.json())
}

View file

@ -0,0 +1,5 @@
import type { Settings } from '../types'
export function areGitFeaturesEnabled(settings: Pick<Settings, 'git_enabled'>): boolean {
return settings.git_enabled !== false
}

View file

@ -0,0 +1,7 @@
import type { Settings } from '../types'
export const DEFAULT_HIDE_GITIGNORED_FILES = true
export function shouldHideGitignoredFiles(settings: Pick<Settings, 'hide_gitignored_files'>): boolean {
return settings.hide_gitignored_files ?? DEFAULT_HIDE_GITIGNORED_FILES
}

View file

@ -0,0 +1,36 @@
import type { VaultEntry } from '../types'
export const TOGGLE_GITIGNORED_VISIBILITY_EVENT = 'tolaria:toggle-gitignored-visibility'
export const GITIGNORED_VISIBILITY_CHANGED_EVENT = 'tolaria:gitignored-visibility-changed'
export const GITIGNORED_VISIBILITY_APPLIED_EVENT = 'tolaria:gitignored-visibility-applied'
interface GitignoredVisibilityChangedDetail {
hide: boolean
}
interface GitignoredVisibilityAppliedDetail extends GitignoredVisibilityChangedDetail {
visiblePaths: string[]
}
export type GitignoredVisibilityChangedEvent = CustomEvent<GitignoredVisibilityChangedDetail>
export type GitignoredVisibilityAppliedEvent = CustomEvent<GitignoredVisibilityAppliedDetail>
function dispatchBrowserEvent<T>(name: string, detail: T): void {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(name, { detail }))
}
export function requestGitignoredVisibilityToggle(): void {
dispatchBrowserEvent(TOGGLE_GITIGNORED_VISIBILITY_EVENT, {})
}
export function notifyGitignoredVisibilityChanged(hide: boolean): void {
dispatchBrowserEvent(GITIGNORED_VISIBILITY_CHANGED_EVENT, { hide })
}
export function notifyGitignoredVisibilityApplied(hide: boolean, entries: VaultEntry[]): void {
dispatchBrowserEvent(GITIGNORED_VISIBILITY_APPLIED_EVENT, {
hide,
visiblePaths: entries.map((entry) => entry.path),
})
}

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { parseGuanghuEnterpriseStatus } from './guanghuEnterprise'
describe('parseGuanghuEnterpriseStatus', () => {
it('keeps only the public topology and authority fields used by the client', () => {
expect(parseGuanghuEnterpriseStatus({
domains: [{
access_state: 'ONLINE_READ_ONLY',
id: 'DOMAIN-MAIN',
mutation_state: 'BLOCKED_UNTIL_PERSONA_STEWARD_BOUND',
name: '光湖主域',
private_metadata: 'must-not-project',
}],
host_state: 'ONLINE',
node_id: 'AW-GZ-001',
raw_shell: 'rejected',
})).toEqual({
domains: [{
accessState: 'ONLINE_READ_ONLY',
id: 'DOMAIN-MAIN',
name: '光湖主域',
}],
execution: 'disabled',
hostState: 'ONLINE',
nodeId: 'AW-GZ-001',
})
})
it('rejects malformed status payloads instead of inventing an online node', () => {
expect(() => parseGuanghuEnterpriseStatus({
domains: [],
host_state: 'ONLINE',
node_id: '',
})).toThrow('guanghu_enterprise_status_invalid')
})
})

View file

@ -0,0 +1,79 @@
export interface GuanghuEnterpriseDomain {
accessState: string
id: string
name: string
}
export interface GuanghuEnterpriseStatus {
domains: GuanghuEnterpriseDomain[]
execution: string
hostState: string
nodeId: string
}
export type GuanghuEnterpriseState =
| {
checkedAt: null
error: null
phase: 'checking'
status: null
}
| {
checkedAt: number
error: null
phase: 'online'
status: GuanghuEnterpriseStatus
}
| {
checkedAt: number
error: string
phase: 'error'
status: null
}
export const INITIAL_GUANGHU_ENTERPRISE_STATE: GuanghuEnterpriseState = {
checkedAt: null,
error: null,
phase: 'checking',
status: null,
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function requiredText(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
export function parseGuanghuEnterpriseStatus(value: unknown): GuanghuEnterpriseStatus {
const payload = record(value)
const nodeId = requiredText(payload?.node_id)
const hostState = requiredText(payload?.host_state)
const domains = Array.isArray(payload?.domains) ? payload.domains : null
if (!payload || !nodeId || !hostState || !domains) {
throw new Error('guanghu_enterprise_status_invalid')
}
const projectedDomains = domains.map(item => {
const domain = record(item)
const id = requiredText(domain?.id)
const name = requiredText(domain?.name)
const accessState = requiredText(domain?.access_state)
if (!domain || !id || !name || !accessState) {
throw new Error('guanghu_enterprise_status_invalid')
}
return { accessState, id, name }
})
return {
domains: projectedDomains,
execution: requiredText(payload.execution) ?? (
payload.raw_shell === 'rejected' ? 'disabled' : 'unknown'
),
hostState,
nodeId,
}
}

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import {
createDeterministicLivingSystemPlan,
createLivingSystemExecutionReceipt,
createLivingSystemEvent,
parseLivingSystemPlan,
validateLivingSystemPlan,
} from './guanghuLivingSystem'
describe('Guanghu living system contract', () => {
it('accepts a model-native navigation plan with a bounded visual scene', () => {
const event = createLivingSystemEvent({
currentRoute: 'world',
intent: 'navigate',
requestedRoute: 'fifth-domain',
worldOpen: true,
receiptIds: ['receipt-001'],
now: 1_785_670_000_000,
eventId: 'event-001',
})
const plan = parseLivingSystemPlan(JSON.stringify({
version: 1,
eventId: 'event-001',
intent: 'navigate',
planId: 'plan-001',
route: 'fifth-domain',
requiredTruth: ['receipt-001'],
scene: {
depth: 'immersive',
motion: 'responsive',
starDensity: 'rich',
connectionEmphasis: 'active-route',
},
}))
expect(validateLivingSystemPlan(event, plan)).toEqual({
accepted: true,
plan,
})
})
it('fails closed when a model plan references truth that was not supplied', () => {
const event = createLivingSystemEvent({
currentRoute: 'world',
intent: 'navigate',
requestedRoute: 'fifth-domain',
worldOpen: true,
receiptIds: [],
eventId: 'event-002',
now: 1,
})
const plan = parseLivingSystemPlan(JSON.stringify({
version: 1,
eventId: 'event-002',
intent: 'navigate',
planId: 'plan-002',
route: 'fifth-domain',
requiredTruth: ['invented-receipt'],
scene: {
depth: 'focused', motion: 'quiet', starDensity: 'balanced', connectionEmphasis: 'contextual',
},
}))
expect(validateLivingSystemPlan(event, plan)).toEqual({
accepted: false,
reason: 'unverified_truth',
})
})
it('redirects protected routes to the login gate while the world is closed', () => {
const event = createLivingSystemEvent({
currentRoute: 'world',
intent: 'navigate',
requestedRoute: 'fifth-domain',
worldOpen: false,
receiptIds: [],
eventId: 'event-003',
now: 1,
})
expect(createDeterministicLivingSystemPlan(event)).toMatchObject({
route: 'world-login',
scene: { depth: 'focused', motion: 'responsive' },
})
})
it('rejects a model plan that substitutes a different host action', () => {
const event = createLivingSystemEvent({
currentRoute: 'world',
eventId: 'event-host-action',
intent: 'open-knowledge',
requestedRoute: 'world',
worldOpen: false,
receiptIds: [],
now: 1,
})
const plan = parseLivingSystemPlan(JSON.stringify({
version: 1,
eventId: event.eventId,
intent: 'open-agent-workspace',
planId: 'substituted-action',
route: 'world',
requiredTruth: [],
scene: {
depth: 'overview',
motion: 'responsive',
starDensity: 'balanced',
connectionEmphasis: 'contextual',
},
}))
expect(validateLivingSystemPlan(event, plan)).toEqual({
accepted: false,
reason: 'intent_mismatch',
})
})
it('rejects chatty or structurally invalid model output', () => {
expect(parseLivingSystemPlan('我来帮你进入第五域')).toBeNull()
expect(parseLivingSystemPlan('{"route":"fifth-domain"}')).toBeNull()
})
it('binds a local executor receipt to the accepted event and plan', () => {
const plan = createDeterministicLivingSystemPlan(createLivingSystemEvent({
currentRoute: 'world',
intent: 'navigate',
requestedRoute: 'zero-core',
worldOpen: true,
receiptIds: [],
eventId: 'event-004',
now: 1,
}))
expect(createLivingSystemExecutionReceipt({ plan, source: 'fallback', now: 2 })).toEqual({
version: 1,
receiptId: 'local-execution:fallback-event-004',
eventId: 'event-004',
intent: 'navigate',
planId: 'fallback-event-004',
route: 'zero-core',
source: 'fallback',
outcome: 'executed',
executedAt: 2,
})
})
})

View file

@ -0,0 +1,205 @@
export const GUANGHU_LIVING_SYSTEM_VERSION = 1 as const
export const GUANGHU_CHANNEL_ROUTES = [
'world',
'world-login',
'zero-core',
'fifth-domain',
'pufferfish',
'eternal-lake-heart',
'light-lake',
'heartbeat-core',
'love-core',
'servers',
] as const
export type GuanghuChannelRoute = typeof GUANGHU_CHANNEL_ROUTES[number]
export type LivingSceneDepth = 'overview' | 'focused' | 'immersive'
export type LivingSceneMotion = 'quiet' | 'responsive' | 'active'
export type LivingSceneStarDensity = 'sparse' | 'balanced' | 'rich'
export type LivingSceneConnectionEmphasis = 'contextual' | 'active-route' | 'network'
export const GUANGHU_LIVING_INTENTS = [
'navigate',
'open-knowledge',
'open-agent-workspace',
'open-local-workspace',
'apply-theme',
] as const
export type GuanghuLivingIntent = typeof GUANGHU_LIVING_INTENTS[number]
export type GuanghuLivingScene = {
depth: LivingSceneDepth
motion: LivingSceneMotion
starDensity: LivingSceneStarDensity
connectionEmphasis: LivingSceneConnectionEmphasis
}
export type GuanghuLivingSystemEvent = {
eventId: string
intent: GuanghuLivingIntent
currentRoute: GuanghuChannelRoute
requestedRoute: GuanghuChannelRoute
appearanceTheme?: string
worldOpen: boolean
receiptIds: string[]
occurredAt: number
}
export type GuanghuLivingSystemPlan = {
version: typeof GUANGHU_LIVING_SYSTEM_VERSION
eventId: string
intent: GuanghuLivingIntent
planId: string
route: GuanghuChannelRoute
requiredTruth: string[]
scene: GuanghuLivingScene
}
export type GuanghuLivingSystemExecutionReceipt = {
version: typeof GUANGHU_LIVING_SYSTEM_VERSION
receiptId: string
eventId: string
intent: GuanghuLivingIntent
planId: string
route: GuanghuChannelRoute
source: 'server' | 'model' | 'fallback'
outcome: 'executed'
executedAt: number
}
type CreateLivingSystemEventInput = Omit<GuanghuLivingSystemEvent, 'occurredAt'> & {
now?: number
}
export type LivingSystemPlanValidation =
| { accepted: true; plan: GuanghuLivingSystemPlan }
| { accepted: false; reason: 'event_mismatch' | 'intent_mismatch' | 'route_mismatch' | 'unverified_truth' | 'world_closed' }
const PROTECTED_ROUTES = new Set<GuanghuChannelRoute>([
'fifth-domain',
'pufferfish',
'eternal-lake-heart',
'light-lake',
'heartbeat-core',
'love-core',
'servers',
])
const SCENE_DEPTHS = new Set<LivingSceneDepth>(['overview', 'focused', 'immersive'])
const SCENE_MOTIONS = new Set<LivingSceneMotion>(['quiet', 'responsive', 'active'])
const STAR_DENSITIES = new Set<LivingSceneStarDensity>(['sparse', 'balanced', 'rich'])
const CONNECTION_EMPHASES = new Set<LivingSceneConnectionEmphasis>(['contextual', 'active-route', 'network'])
const LIVING_INTENTS = new Set<GuanghuLivingIntent>(GUANGHU_LIVING_INTENTS)
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string')
}
function isRoute(value: unknown): value is GuanghuChannelRoute {
return typeof value === 'string' && (GUANGHU_CHANNEL_ROUTES as readonly string[]).includes(value)
}
function isLivingSystemPlan(value: unknown): value is GuanghuLivingSystemPlan {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<GuanghuLivingSystemPlan>
const scene = candidate.scene
return candidate.version === GUANGHU_LIVING_SYSTEM_VERSION
&& typeof candidate.eventId === 'string'
&& candidate.eventId.length > 0
&& LIVING_INTENTS.has(candidate.intent!)
&& typeof candidate.planId === 'string'
&& candidate.planId.length > 0
&& isRoute(candidate.route)
&& isStringArray(candidate.requiredTruth)
&& Boolean(scene)
&& SCENE_DEPTHS.has(scene!.depth)
&& SCENE_MOTIONS.has(scene!.motion)
&& STAR_DENSITIES.has(scene!.starDensity)
&& CONNECTION_EMPHASES.has(scene!.connectionEmphasis)
}
export function createLivingSystemEvent(input: CreateLivingSystemEventInput): GuanghuLivingSystemEvent {
return {
eventId: input.eventId,
intent: input.intent,
currentRoute: input.currentRoute,
requestedRoute: input.requestedRoute,
...(input.appearanceTheme ? { appearanceTheme: input.appearanceTheme } : {}),
worldOpen: input.worldOpen,
receiptIds: [...input.receiptIds],
occurredAt: input.now ?? Date.now(),
}
}
export function parseLivingSystemPlan(output: string): GuanghuLivingSystemPlan | null {
try {
const parsed: unknown = JSON.parse(output.trim())
return isLivingSystemPlan(parsed) ? parsed : null
} catch {
return null
}
}
export function validateLivingSystemPlan(
event: GuanghuLivingSystemEvent,
plan: GuanghuLivingSystemPlan | null,
): LivingSystemPlanValidation {
if (!plan || plan.eventId !== event.eventId) return { accepted: false, reason: 'event_mismatch' }
if (plan.intent !== event.intent) return { accepted: false, reason: 'intent_mismatch' }
if (plan.route !== event.requestedRoute && plan.route !== 'world-login') {
return { accepted: false, reason: 'route_mismatch' }
}
const suppliedTruth = new Set(event.receiptIds)
if (plan.requiredTruth.some(receiptId => !suppliedTruth.has(receiptId))) {
return { accepted: false, reason: 'unverified_truth' }
}
if (!event.worldOpen && PROTECTED_ROUTES.has(plan.route)) {
return { accepted: false, reason: 'world_closed' }
}
return { accepted: true, plan }
}
export function createDeterministicLivingSystemPlan(
event: GuanghuLivingSystemEvent,
): GuanghuLivingSystemPlan {
const route = !event.worldOpen && PROTECTED_ROUTES.has(event.requestedRoute)
? 'world-login'
: event.requestedRoute
const immersive = event.worldOpen && route !== 'world' && route !== 'world-login'
return {
version: GUANGHU_LIVING_SYSTEM_VERSION,
eventId: event.eventId,
intent: event.intent,
planId: `fallback-${event.eventId}`,
route,
requiredTruth: [],
scene: {
depth: immersive ? 'immersive' : route === 'world' ? 'overview' : 'focused',
motion: route === 'world' ? 'quiet' : 'responsive',
starDensity: immersive ? 'rich' : 'balanced',
connectionEmphasis: immersive ? 'active-route' : 'contextual',
},
}
}
export function createLivingSystemExecutionReceipt({
plan,
source,
now = Date.now(),
}: {
plan: GuanghuLivingSystemPlan
source: GuanghuLivingSystemExecutionReceipt['source']
now?: number
}): GuanghuLivingSystemExecutionReceipt {
return {
version: GUANGHU_LIVING_SYSTEM_VERSION,
receiptId: `local-execution:${plan.planId}`,
eventId: plan.eventId,
intent: plan.intent,
planId: plan.planId,
route: plan.route,
source,
outcome: 'executed',
executedAt: now,
}
}

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
INITIAL_GUANGHU_ROUTER_STATE,
markGuanghuAuthorizationApproved,
reduceGuanghuRouterEvent,
} from './guanghuRouter'
describe('Guanghu Router event state', () => {
it('becomes online only from a server-connected receipt', () => {
const state = reduceGuanghuRouterEvent(INITIAL_GUANGHU_ROUTER_STATE, {
type: 'router.connected',
receipt: {
connection_id: 'connection-001',
node_id: 'JD-FD-PRIMARY',
receipt_id: 'receipt-001',
state: 'online',
},
})
expect(state.status).toBe('online')
expect(state.latestReceipt?.receipt_id).toBe('receipt-001')
})
it('keeps valid authorization cards and rejects malformed placeholders', () => {
const online = {
...INITIAL_GUANGHU_ROUTER_STATE,
status: 'online' as const,
}
const malformed = reduceGuanghuRouterEvent(online, {
type: 'authorization.requested',
digest: 'placeholder',
workorder: { id: 'workorder' },
})
expect(malformed.cards).toEqual([])
const requested = reduceGuanghuRouterEvent(online, {
type: 'authorization.requested',
digest: 'a'.repeat(64),
workorder: {
action: 'read-navigation-map',
description: '进入第五域',
expiresAt: 2_000_000_000,
id: '203e12af-f821-4b62-b80f-b3d73df05161',
persona: { name: '铸渊', pid: 'ICE-GL-ZY001' },
scope: 'server-login',
target: 'JD-FD-PRIMARY',
},
})
expect(requested.cards).toHaveLength(1)
})
it('turns transport failures and close events into visible non-online states', () => {
const failed = reduceGuanghuRouterEvent(INITIAL_GUANGHU_ROUTER_STATE, {
type: 'router.error',
error: 'device_not_registered',
})
expect(failed.status).toBe('error')
expect(failed.error).toBe('device_not_registered')
const closed = reduceGuanghuRouterEvent(failed, {
type: 'router.closed',
receipt: {
connection_id: 'connection-001',
node_id: 'JD-FD-PRIMARY',
receipt_id: 'receipt-002',
state: 'offline',
},
})
expect(closed.status).toBe('offline')
})
it('surfaces the real repository upload receipt returned after approval', () => {
const state = markGuanghuAuthorizationApproved({
...INITIAL_GUANGHU_ROUTER_STATE,
cards: [],
status: 'online',
}, 'workorder', {
receipt: {
diagnostic_code: 'broadcast_console_approved',
state: 'approved',
},
repository_transfer: {
ok: true,
receipt: {
action: 'push-repository',
diagnostic_code: 'repo_push_succeeded',
state: 'succeeded',
},
},
})
expect(state.latestReceipt?.diagnostic_code).toBe('repo_push_succeeded')
expect(state.error).toBeNull()
})
})

View file

@ -0,0 +1,180 @@
export type GuanghuRouterStatus = 'offline' | 'connecting' | 'online' | 'error'
export interface GuanghuRouterReceipt {
action?: string
connection_id?: string
diagnostic_code?: string
node_id?: string
receipt_id?: string
state: string
workorder_id?: string
}
export interface GuanghuRouterWorkorder {
action: string
description: string
expiresAt: number
id: string
persona: {
name: string
pid: string
}
resource?: string
scope: string
target: string
}
export interface GuanghuAuthorizationCard {
digest: string
workorder: GuanghuRouterWorkorder
}
export interface GuanghuRouterState {
cards: GuanghuAuthorizationCard[]
error: string | null
latestReceipt: GuanghuRouterReceipt | null
status: GuanghuRouterStatus
}
export const INITIAL_GUANGHU_ROUTER_STATE: GuanghuRouterState = {
cards: [],
error: null,
latestReceipt: null,
status: 'offline',
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function receiptFrom(value: unknown): GuanghuRouterReceipt | null {
const source = record(value)
const state = text(source?.state)
if (!source || !state) return null
return {
state,
action: text(source.action) ?? undefined,
connection_id: text(source.connection_id) ?? undefined,
diagnostic_code: text(source.diagnostic_code) ?? undefined,
node_id: text(source.node_id) ?? undefined,
receipt_id: text(source.receipt_id) ?? undefined,
workorder_id: text(source.workorder_id) ?? undefined,
}
}
function workorderFrom(value: unknown): GuanghuRouterWorkorder | null {
const source = record(value)
const persona = record(source?.persona)
const id = text(source?.id)
const action = text(source?.action)
const description = text(source?.description) ?? ''
const expiresAt = numberValue(source?.expiresAt)
const name = text(persona?.name)
const pid = text(persona?.pid)
const scope = text(source?.scope)
const target = text(source?.target)
if (!id || !action || expiresAt === null || !name || !pid || !scope || !target) return null
return {
action,
description,
expiresAt,
id,
persona: { name, pid },
resource: text(source?.resource) ?? undefined,
scope,
target,
}
}
function cardFrom(event: Record<string, unknown>): GuanghuAuthorizationCard | null {
const digest = text(event.digest)?.toLowerCase()
const workorder = workorderFrom(event.workorder)
if (!digest || digest.length !== 64 || !/^[a-f0-9]+$/.test(digest) || !workorder) return null
return { digest, workorder }
}
export function reduceGuanghuRouterEvent(
state: GuanghuRouterState,
value: unknown,
): GuanghuRouterState {
const event = record(value)
const type = text(event?.type)
if (!event || !type) return state
if (type === 'router.connected') {
const receipt = receiptFrom(event.receipt)
if (!receipt || receipt.state !== 'online' || !receipt.receipt_id || !receipt.connection_id) {
return {
...state,
error: 'guanghu_router_connected_receipt_invalid',
status: 'error',
}
}
return {
...state,
error: null,
latestReceipt: receipt,
status: 'online',
}
}
if (type === 'authorization.requested') {
const card = cardFrom(event)
if (!card) return state
return {
...state,
cards: [
...state.cards.filter(item => item.workorder.id !== card.workorder.id),
card,
],
}
}
if (type === 'router.closed') {
return {
...state,
error: null,
latestReceipt: receiptFrom(event.receipt) ?? state.latestReceipt,
status: 'offline',
}
}
if (type === 'router.error') {
return {
...state,
error: text(event.error) ?? 'guanghu_router_unknown_error',
status: 'error',
}
}
return state
}
export function markGuanghuAuthorizationApproved(
state: GuanghuRouterState,
workorderId: string,
response: unknown,
): GuanghuRouterState {
const payload = record(response)
const transfer = record(payload?.repository_transfer)
const transferReceipt = receiptFrom(transfer?.receipt)
const transferError = transfer?.ok === false
? text(transfer.diagnostic_code) ?? 'repo_push_failed'
: null
return {
...state,
cards: state.cards.filter(card => card.workorder.id !== workorderId),
error: transferError,
latestReceipt: transferReceipt ?? receiptFrom(payload?.receipt) ?? state.latestReceipt,
}
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { parseGuanghuShanghaiNodeStatus } from './guanghuShanghaiNode'
describe('parseGuanghuShanghaiNodeStatus', () => {
it('keeps native residency distinct from hosted maintenance', () => {
expect(parseGuanghuShanghaiNodeStatus({
node_id: 'BS-SH-005',
reachability: 'online',
region: 'shanghai',
runtime: 'guanghu_os_native',
ttl: 39,
})).toMatchObject({ runtime: 'guanghu_os_native', ttl: 39 })
expect(parseGuanghuShanghaiNodeStatus({
node_id: 'BS-SH-005',
reachability: 'online',
region: 'shanghai',
runtime: 'hosted_maintenance',
ttl: 53,
})).toMatchObject({ runtime: 'hosted_maintenance', ttl: 53 })
})
it('rejects invented node identities', () => {
expect(() => parseGuanghuShanghaiNodeStatus({
node_id: 'AW-GZ-001',
reachability: 'online',
region: 'shanghai',
runtime: 'guanghu_os_native',
ttl: 39,
})).toThrow('guanghu_shanghai_node_receipt_invalid')
})
})

View file

@ -0,0 +1,52 @@
export interface GuanghuShanghaiNodeStatus {
nodeId: 'BS-SH-005'
reachability: 'online' | 'unreachable'
region: 'shanghai'
runtime: 'guanghu_os_native' | 'hosted_maintenance' | 'unknown'
ttl: number | null
}
export interface GuanghuShanghaiNodeState {
checkedAt: number | null
error: string | null
phase: 'checking' | 'online' | 'error'
status: GuanghuShanghaiNodeStatus | null
}
export const INITIAL_GUANGHU_SHANGHAI_NODE_STATE: GuanghuShanghaiNodeState = {
checkedAt: null,
error: null,
phase: 'checking',
status: null,
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
export function parseGuanghuShanghaiNodeStatus(
value: unknown,
): GuanghuShanghaiNodeStatus {
const status = record(value)
const runtime = status?.runtime
const reachability = status?.reachability
if (
status?.node_id !== 'BS-SH-005'
|| status.region !== 'shanghai'
|| (reachability !== 'online' && reachability !== 'unreachable')
|| !['guanghu_os_native', 'hosted_maintenance', 'unknown'].includes(
typeof runtime === 'string' ? runtime : '',
)
) {
throw new Error('guanghu_shanghai_node_receipt_invalid')
}
return {
nodeId: 'BS-SH-005',
reachability,
region: 'shanghai',
runtime: runtime as GuanghuShanghaiNodeStatus['runtime'],
ttl: typeof status.ttl === 'number' ? status.ttl : null,
}
}

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
GUANGHU_THEMES,
normalizeGuanghuTheme,
readGuanghuTheme,
} from './guanghuTheme'
describe('guanghuTheme', () => {
it('keeps the companion theme choices explicit and stable', () => {
expect(GUANGHU_THEMES).toEqual([
'native',
'guanghu-native-galaxy',
'lake-light',
'lake-dark',
'aurora-dark',
'ice-heart',
'yaoming-purple',
'starfield-indigo',
'dawn-gold',
'tundra-green',
'peach-mist',
])
})
it('falls back to the native Tolaria theme for unknown values', () => {
expect(normalizeGuanghuTheme('old-blue-skin')).toBe('native')
expect(normalizeGuanghuTheme(null)).toBe('native')
})
it('starts a new installation with the Guanghu native galaxy theme', () => {
expect(readGuanghuTheme({ getItem: () => null })).toBe('guanghu-native-galaxy')
})
it('accepts every supported companion theme', () => {
expect(normalizeGuanghuTheme('guanghu-native-galaxy')).toBe('guanghu-native-galaxy')
expect(normalizeGuanghuTheme('lake-light')).toBe('lake-light')
expect(normalizeGuanghuTheme('lake-dark')).toBe('lake-dark')
expect(normalizeGuanghuTheme('yaoming-purple')).toBe('yaoming-purple')
expect(normalizeGuanghuTheme('dawn-gold')).toBe('dawn-gold')
})
})

View file

@ -0,0 +1,55 @@
export const GUANGHU_THEME_STORAGE_KEY = 'guanghu-theme'
export const GUANGHU_THEMES = [
'native',
'guanghu-native-galaxy',
'lake-light',
'lake-dark',
'aurora-dark',
'ice-heart',
'yaoming-purple',
'starfield-indigo',
'dawn-gold',
'tundra-green',
'peach-mist',
] as const
export type GuanghuTheme = typeof GUANGHU_THEMES[number]
export const GUANGHU_THEME_LABELS: Record<GuanghuTheme, string> = {
native: '跟随原生',
'guanghu-native-galaxy': '光湖原生星系',
'lake-light': '湖光浅色(亮)',
'lake-dark': '湖心深色(暗)',
'aurora-dark': '极光深海',
'ice-heart': '冰心蓝',
'yaoming-purple': '曜冥紫',
'starfield-indigo': '星野靛青',
'dawn-gold': '晨曦金',
'tundra-green': '苔原绿',
'peach-mist': '桃雾晨光',
}
export function normalizeGuanghuTheme(value: unknown): GuanghuTheme {
return typeof value === 'string' && (GUANGHU_THEMES as readonly string[]).includes(value)
? value as GuanghuTheme
: 'native'
}
export function readGuanghuTheme(storage: Pick<Storage, 'getItem'>): GuanghuTheme {
try {
const storedTheme = storage.getItem(GUANGHU_THEME_STORAGE_KEY)
return storedTheme === null
? 'guanghu-native-galaxy'
: normalizeGuanghuTheme(storedTheme)
} catch {
return 'guanghu-native-galaxy'
}
}
export function writeGuanghuTheme(storage: Pick<Storage, 'setItem'>, theme: GuanghuTheme): void {
try {
storage.setItem(GUANGHU_THEME_STORAGE_KEY, theme)
} catch {
// Storage may be unavailable in restricted browser contexts.
}
}

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_GUANGHU_ROUTER_STATE } from './guanghuRouter'
import { buildGuanghuWorldGraph } from './guanghuWorldGraph'
describe('buildGuanghuWorldGraph', () => {
it('projects only the five top-level domains on the world home', () => {
const graph = buildGuanghuWorldGraph(INITIAL_GUANGHU_ROUTER_STATE)
expect(graph.nodes.map(node => node.id)).toEqual([
'main-domain',
'branch-domain',
'zero-domain',
'zero-sense-domain',
'fifth-domain',
])
expect(graph.nodes.find(node => node.id === 'main-domain')?.status).toBe('pending')
expect(graph.nodes.find(node => node.id === 'zero-sense-domain')?.status).toBe('restricted')
expect(graph.nodes.find(node => node.id === 'fifth-domain')?.status).toBe('pending')
expect(graph.nodes.some(node => node.kind !== 'domain')).toBe(false)
})
it('projects the real router receipt only into the Fifth Domain boundary', () => {
const graph = buildGuanghuWorldGraph({
cards: [],
error: null,
latestReceipt: {
connection_id: 'connection-001',
node_id: 'JD-FD-PRIMARY',
receipt_id: 'receipt-001',
state: 'online',
},
status: 'online',
})
expect(graph.nodes.find(node => node.id === 'fifth-domain')).toMatchObject({
label: '第五域',
status: 'online',
statusLabel: '当前所在',
})
expect(graph.nodes.some(node => node.label === 'JD-FD-PRIMARY')).toBe(false)
})
it('projects enterprise online-read-only domains without granting mutation authority', () => {
const graph = buildGuanghuWorldGraph(INITIAL_GUANGHU_ROUTER_STATE, {
checkedAt: 1_785_399_662_000,
error: null,
status: {
domains: [
{ accessState: 'ONLINE_READ_ONLY', id: 'DOMAIN-MAIN', name: '光湖主域' },
{ accessState: 'ONLINE_READ_ONLY', id: 'DOMAIN-SUB', name: '光湖分域' },
{ accessState: 'ONLINE_READ_ONLY', id: 'DOMAIN-ZERO', name: '光湖零域' },
{ accessState: 'ONLINE_READ_ONLY', id: 'DOMAIN-ZS', name: '零感域' },
],
execution: 'disabled',
hostState: 'ONLINE',
nodeId: 'AW-GZ-001',
},
phase: 'online',
})
expect(graph.nodes.find(node => node.id === 'main-domain')).toMatchObject({
status: 'available',
statusLabel: '在线只读',
})
expect(graph.nodes.find(node => node.id === 'fifth-domain')?.status).toBe('pending')
})
it('does not leak Fifth Domain server nodes onto the world home', () => {
const graph = buildGuanghuWorldGraph(
INITIAL_GUANGHU_ROUTER_STATE,
undefined,
{
checkedAt: 1,
error: null,
phase: 'online',
status: {
nodeId: 'BS-SH-005',
reachability: 'online',
region: 'shanghai',
runtime: 'hosted_maintenance',
ttl: 53,
},
},
)
expect(graph.nodes.some(node => node.label === 'BS-SH-005')).toBe(false)
expect(graph.edges).toEqual([])
})
})

View file

@ -0,0 +1,145 @@
import type { GuanghuRouterState } from './guanghuRouter'
import type { GuanghuEnterpriseState } from './guanghuEnterprise'
import type { GuanghuShanghaiNodeState } from './guanghuShanghaiNode'
export type WorldNodeKind = 'domain'
export type WorldNodeStatus = 'online' | 'pending' | 'restricted' | 'available'
export type WorldNodeAction = 'enter-fifth-domain' | 'none'
export interface WorldGraphNode {
action: WorldNodeAction
description: string
id: string
kind: WorldNodeKind
label: string
status: WorldNodeStatus
statusLabel: string
width: number
x: number
y: number
}
export interface WorldGraphEdge {
from: string
id: string
status: WorldNodeStatus
to: string
}
export interface WorldGraph {
edges: WorldGraphEdge[]
nodes: WorldGraphNode[]
}
const DOMAIN_NODES: WorldGraphNode[] = [
{
action: 'none',
description: '公共、正式、稳定可抵达的光湖世界域。',
id: 'main-domain',
kind: 'domain',
label: '光湖主域',
status: 'pending',
statusLabel: '可遥望',
width: 176,
x: 580,
y: 132,
},
{
action: 'none',
description: '用户与组织边界清楚、彼此连接的协作域。',
id: 'branch-domain',
kind: 'domain',
label: '光湖分域',
status: 'pending',
statusLabel: '可遥望',
width: 164,
x: 900,
y: 262,
},
{
action: 'none',
description: '用于实验、校准、回执与可回滚变化的语言域。',
id: 'zero-domain',
kind: 'domain',
label: '光湖零域',
status: 'pending',
statusLabel: '可遥望',
width: 164,
x: 250,
y: 318,
},
{
action: 'none',
description: '承载团队运行、技术维护与真实落地边界的语言域。',
id: 'zero-sense-domain',
kind: 'domain',
label: '光湖零感域',
status: 'restricted',
statusLabel: '可遥望',
width: 170,
x: 802,
y: 528,
},
{
action: 'enter-fifth-domain',
description: '独立私人语言域;内部系统仅在授权进入后可见。',
id: 'fifth-domain',
kind: 'domain',
label: '第五域',
status: 'pending',
statusLabel: '等待真实回执',
width: 186,
x: 292,
y: 532,
},
]
function enterpriseStatus(
node: WorldGraphNode,
enterprise?: GuanghuEnterpriseState,
): Pick<WorldGraphNode, 'status' | 'statusLabel'> | null {
const domain = enterprise?.status?.domains.find(item => {
if (node.id === 'main-domain') return item.name === '光湖主域'
if (node.id === 'branch-domain') return item.name === '光湖分域'
if (node.id === 'zero-domain') return item.name === '光湖零域'
return node.id === 'zero-sense-domain' && (item.name === '光湖零感域' || item.name === '零感域')
})
if (!domain) return null
if (domain.accessState === 'ONLINE_READ_ONLY') {
return { status: 'available', statusLabel: '在线只读' }
}
return { status: 'pending', statusLabel: domain.accessState }
}
export function buildGuanghuWorldGraph(
router: GuanghuRouterState,
enterprise?: GuanghuEnterpriseState,
shanghai?: GuanghuShanghaiNodeState,
): WorldGraph {
// Keep the adapter in the API, but do not project a private server on the
// public five-domain surface.
void shanghai
const nodes = DOMAIN_NODES.map(node => {
if (node.id === 'fifth-domain') {
if (router.status === 'online' && router.latestReceipt?.state === 'online') {
return { ...node, status: 'online' as const, statusLabel: '当前所在' }
}
if (router.status === 'error') {
return { ...node, status: 'restricted' as const, statusLabel: '当前不可进入' }
}
return node
}
return { ...node, ...enterpriseStatus(node, enterprise) }
})
// The world home is a five-domain semantic projection, not a server graph.
// Internal systems, servers, channels and relations appear only after the
// corresponding domain route has passed its authorization boundary.
return { edges: [], nodes }
}
export function graphNodeById(graph: WorldGraph, id: string): WorldGraphNode {
return graph.nodes.find(node => node.id === id) ?? graph.nodes[0]
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
applyGuanghuWorldLoginReceipt,
INITIAL_GUANGHU_WORLD_LOGIN_STATE,
} from './guanghuWorldLogin'
describe('applyGuanghuWorldLoginReceipt', () => {
it('keeps the mailbox private while projecting a trusted handoff', () => {
const state = applyGuanghuWorldLoginReceipt(INITIAL_GUANGHU_WORLD_LOGIN_STATE, {
node_id: 'JD-FD-PRIMARY',
request_url: 'https://guanghulab.com/authz/request/WO-001',
state: 'waiting_for_email',
workorder_id: 'WO-001',
})
expect(state).toMatchObject({
error: null,
nodeId: 'JD-FD-PRIMARY',
phase: 'waiting_for_email',
workorderId: 'WO-001',
})
expect(state).not.toHaveProperty('email')
expect(state).not.toHaveProperty('mailbox')
})
it('opens the world only after the claimed navigation map arrives', () => {
const state = applyGuanghuWorldLoginReceipt(INITIAL_GUANGHU_WORLD_LOGIN_STATE, {
navigation: { map_hash: 'map-001', domains: 5 },
node_id: 'JD-FD-PRIMARY',
state: 'online',
workorder_id: 'WO-001',
})
expect(state.phase).toBe('online')
expect(state.navigation).toEqual({ map_hash: 'map-001', domains: 5 })
})
it('rejects malformed receipts and untrusted handoff URLs', () => {
expect(applyGuanghuWorldLoginReceipt(INITIAL_GUANGHU_WORLD_LOGIN_STATE, {
request_url: 'https://example.com/steal',
state: 'waiting_for_email',
}).requestUrl).toBeNull()
expect(applyGuanghuWorldLoginReceipt(
INITIAL_GUANGHU_WORLD_LOGIN_STATE,
{ state: 'invented' },
).phase).toBe('error')
})
})

View file

@ -0,0 +1,67 @@
export type GuanghuWorldLoginPhase =
| 'idle'
| 'requesting'
| 'waiting_for_email'
| 'online'
| 'error'
export interface GuanghuWorldLoginState {
error: string | null
navigation: Record<string, unknown> | null
nodeId: string
phase: GuanghuWorldLoginPhase
requestUrl: string | null
workorderId: string | null
}
export const INITIAL_GUANGHU_WORLD_LOGIN_STATE: GuanghuWorldLoginState = {
error: null,
navigation: null,
nodeId: 'JD-FD-PRIMARY',
phase: 'idle',
requestUrl: null,
workorderId: null,
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
export function applyGuanghuWorldLoginReceipt(
current: GuanghuWorldLoginState,
value: unknown,
): GuanghuWorldLoginState {
const receipt = record(value)
const phase = text(receipt?.state)
if (!receipt || !matchesPhase(phase)) {
return {
...current,
error: 'guanghu_world_login_receipt_invalid',
phase: 'error',
}
}
const navigation = record(receipt.navigation)
return {
error: null,
navigation,
nodeId: text(receipt.node_id) ?? current.nodeId,
phase,
requestUrl: trustedRequestUrl(receipt.request_url),
workorderId: text(receipt.workorder_id),
}
}
function matchesPhase(value: string | null): value is Exclude<GuanghuWorldLoginPhase, 'requesting' | 'error'> {
return value === 'idle' || value === 'waiting_for_email' || value === 'online'
}
function trustedRequestUrl(value: unknown): string | null {
const url = text(value)
return url?.startsWith('https://guanghulab.com/authz/') ? url : null
}

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
APP_LOCALES,
EN_TRANSLATIONS,
localeCatalogLocales,
localeDisplayName,
normalizeUiLanguagePreference,
resolveEffectiveLocale,
serializeUiLanguagePreference,
translate,
} from './i18n'
describe('i18n', () => {
it('uses supported system languages before falling back to English', () => {
expect(resolveEffectiveLocale(null, ['zh-CN'])).toBe('zh-CN')
expect(resolveEffectiveLocale(null, ['zh-TW'])).toBe('zh-TW')
expect(resolveEffectiveLocale(null, ['es-MX'])).toBe('es-419')
expect(resolveEffectiveLocale('system', ['fr-FR'])).toBe('fr-FR')
expect(resolveEffectiveLocale('system', ['xx-ZZ'])).toBe('en')
})
it('normalizes current and legacy language preferences', () => {
expect(normalizeUiLanguagePreference(' zh-cn ')).toBe('zh-CN')
expect(normalizeUiLanguagePreference('zh-Hans')).toBe('zh-CN')
expect(normalizeUiLanguagePreference('zh-Hant')).toBe('zh-TW')
expect(normalizeUiLanguagePreference('zh-HK')).toBe('zh-TW')
expect(normalizeUiLanguagePreference('fr-FR')).toBe('fr-FR')
expect(normalizeUiLanguagePreference('auto')).toBe('system')
expect(normalizeUiLanguagePreference('xx-ZZ')).toBeNull()
})
it('serializes system preference as the settings default', () => {
expect(serializeUiLanguagePreference('system')).toBeNull()
expect(serializeUiLanguagePreference('zh-Hans')).toBe('zh-CN')
expect(serializeUiLanguagePreference('zh-Hant')).toBe('zh-TW')
})
it('keeps English locale metadata aligned with the locale registry', () => {
expect(APP_LOCALES).toContain('zh-CN')
expect(APP_LOCALES).toContain('zh-TW')
expect(APP_LOCALES).toContain('ko-KR')
expect(localeDisplayName('pt-BR', 'en')).toBe('Portuguese (Brazil)')
})
it('formats locale display names in the active language', () => {
expect(localeDisplayName('zh-CN', 'zh-CN')).toBe('简体中文')
expect(localeDisplayName('zh-TW', 'zh-TW')).toBe('繁體中文')
expect(localeDisplayName('en', 'zh-CN')).toBe('英文')
expect(localeDisplayName('es-419', 'en')).toBe('Spanish (Latin America)')
expect(localeDisplayName('id-ID', 'id-ID')).toBe('Bahasa Indonesia')
expect(localeDisplayName('uk-UA', 'uk-UA')).toBe('Українська')
expect(localeDisplayName('sv-SE', 'sv-SE')).toBe('Svenska')
expect(localeDisplayName('sk-SK', 'sk-SK')).toBe('Slovenčina')
})
it('keeps locale label keys present in English', () => {
expect(EN_TRANSLATIONS['locale.itIT']).toBe('Italian')
expect(EN_TRANSLATIONS['locale.koKR']).toBe('Korean')
expect(EN_TRANSLATIONS['locale.idID']).toBe('Indonesian')
expect(EN_TRANSLATIONS['locale.ukUA']).toBe('Ukrainian')
expect(EN_TRANSLATIONS['locale.svSE']).toBe('Swedish')
expect(EN_TRANSLATIONS['locale.skSK']).toBe('Slovak')
})
it('loads a translation catalog for every configured locale', () => {
expect(localeCatalogLocales()).toEqual(APP_LOCALES)
})
it('drops English-only plural suffix values for non-English locales', () => {
expect(translate('en', 'status.conflict.count', { count: 2, plural: 's' })).toBe('2 conflicts')
expect(translate('zh-CN', 'status.conflict.count', { count: 2, plural: 's' })).toBe('2 个冲突')
expect(translate('zh-TW', 'status.conflict.count', { count: 2, plural: 's' })).toBe('2 個衝突')
})
it('uses platform-neutral Chinese labels for revealing files and folders', () => {
const revealKeys = ['sidebar.action.revealFolderMenu', 'editor.toolbar.revealFile'] as const
for (const key of revealKeys) {
expect(translate('zh-CN', key)).toBe('在文件管理器中显示')
expect(translate('zh-CN', key)).not.toContain('访达')
expect(translate('zh-TW', key)).toBe('在檔案管理器中顯示')
expect(translate('zh-TW', key)).not.toContain('訪達')
}
})
})

View file

@ -0,0 +1,359 @@
import EN_TRANSLATIONS from './locales/en.json'
export const DEFAULT_APP_LOCALE = 'en'
export const SYSTEM_UI_LANGUAGE = 'system'
export const APP_LOCALES = [
'en',
'it-IT',
'fr-FR',
'de-DE',
'ru-RU',
'es-ES',
'pt-BR',
'pt-PT',
'es-419',
'zh-CN',
'zh-TW',
'ja-JP',
'ko-KR',
'vi',
'pl-PL',
'be-BY',
'be-Latn',
'id-ID',
'uk-UA',
'sv-SE',
'sk-SK',
] as const
export type AppLocale = typeof APP_LOCALES[number]
export type UiLanguagePreference = typeof SYSTEM_UI_LANGUAGE | AppLocale
export type TranslationCatalog = typeof EN_TRANSLATIONS
export type TranslationKey = keyof TranslationCatalog
export type TranslationValues = Record<string, string | number>
type LocaleDefinition = {
code: AppLocale
dateLocale: string
labelKey: TranslationKey
aliases: readonly string[]
searchKeywords: readonly string[]
}
const LOCALE_DEFINITIONS: Record<AppLocale, LocaleDefinition> = {
en: {
code: 'en',
dateLocale: 'en-US',
labelKey: 'locale.en',
aliases: ['en', 'en-us', 'en-gb', 'en-ca', 'en-au'],
searchKeywords: ['english', 'en'],
},
'it-IT': {
code: 'it-IT',
dateLocale: 'it-IT',
labelKey: 'locale.itIT',
aliases: ['it', 'it-it'],
searchKeywords: ['italian', 'italiano', 'it', 'it-it'],
},
'fr-FR': {
code: 'fr-FR',
dateLocale: 'fr-FR',
labelKey: 'locale.frFR',
aliases: ['fr', 'fr-fr'],
searchKeywords: ['french', 'francais', 'français', 'fr', 'fr-fr'],
},
'de-DE': {
code: 'de-DE',
dateLocale: 'de-DE',
labelKey: 'locale.deDE',
aliases: ['de', 'de-de'],
searchKeywords: ['german', 'deutsch', 'de', 'de-de'],
},
'ru-RU': {
code: 'ru-RU',
dateLocale: 'ru-RU',
labelKey: 'locale.ruRU',
aliases: ['ru', 'ru-ru'],
searchKeywords: ['russian', 'russkiy', 'русский', 'ru', 'ru-ru'],
},
'es-ES': {
code: 'es-ES',
dateLocale: 'es-ES',
labelKey: 'locale.esES',
aliases: ['es-es'],
searchKeywords: ['spanish', 'espanol', 'español', 'spain', 'es', 'es-es'],
},
'pt-BR': {
code: 'pt-BR',
dateLocale: 'pt-BR',
labelKey: 'locale.ptBR',
aliases: ['pt-br'],
searchKeywords: ['portuguese', 'brasil', 'brazilian', 'pt', 'pt-br'],
},
'pt-PT': {
code: 'pt-PT',
dateLocale: 'pt-PT',
labelKey: 'locale.ptPT',
aliases: ['pt-pt'],
searchKeywords: ['portuguese', 'portugal', 'european', 'pt-pt'],
},
'es-419': {
code: 'es-419',
dateLocale: 'es-419',
labelKey: 'locale.es419',
aliases: [
'es-419',
'es-ar',
'es-bo',
'es-cl',
'es-co',
'es-cr',
'es-cu',
'es-do',
'es-ec',
'es-gt',
'es-hn',
'es-mx',
'es-ni',
'es-pa',
'es-pe',
'es-pr',
'es-py',
'es-sv',
'es-us',
'es-uy',
'es-ve',
],
searchKeywords: ['spanish', 'latin', 'latam', 'latin america', 'es-419'],
},
'zh-CN': {
code: 'zh-CN',
dateLocale: 'zh-CN',
labelKey: 'locale.zhCN',
aliases: ['zh', 'zh-cn', 'zh-hans', 'zh-sg'],
searchKeywords: ['chinese', 'simplified', 'zh', 'zh-cn', '中文', '简体中文'],
},
'zh-TW': {
code: 'zh-TW',
dateLocale: 'zh-TW',
labelKey: 'locale.zhTW',
aliases: ['zh-tw', 'zh-hant', 'zh-hk', 'zh-mo'],
searchKeywords: ['chinese', 'traditional', 'zh-tw', 'zh-hant', '中文', '繁體中文', '繁体中文'],
},
'ja-JP': {
code: 'ja-JP',
dateLocale: 'ja-JP',
labelKey: 'locale.jaJP',
aliases: ['ja', 'ja-jp'],
searchKeywords: ['japanese', 'nihongo', '日本語', 'ja', 'ja-jp'],
},
'ko-KR': {
code: 'ko-KR',
dateLocale: 'ko-KR',
labelKey: 'locale.koKR',
aliases: ['ko', 'ko-kr'],
searchKeywords: ['korean', 'hangul', '한국어', 'ko', 'ko-kr'],
},
vi: {
code: 'vi',
dateLocale: 'vi-VN',
labelKey: 'locale.vi',
aliases: ['vi', 'vi-vn'],
searchKeywords: ['vietnamese', 'vietnam', 'viet nam', 'tiếng việt', 'tieng viet', 'việt nam', 'vi'],
},
'pl-PL': {
code: 'pl-PL',
dateLocale: 'pl-PL',
labelKey: 'locale.plPL',
aliases: ['pl', 'pl-pl'],
searchKeywords: ['polish', 'polski', 'polska', 'pl', 'pl-pl'],
},
'be-BY': {
code: 'be-BY',
dateLocale: 'be-BY',
labelKey: 'locale.beBY',
aliases: ['be', 'be-by'],
searchKeywords: ['belarusian', 'беларуская', 'be', 'be-by'],
},
'be-Latn': {
code: 'be-Latn',
dateLocale: 'be-Latn',
labelKey: 'locale.beLatn',
aliases: ['be-latn'],
searchKeywords: ['belarusian', 'bielaruskaja', 'lacinka', 'be-latn'],
},
'id-ID': {
code: 'id-ID',
dateLocale: 'id-ID',
labelKey: 'locale.idID',
aliases: ['id','id-id'],
searchKeywords: ['indonesia', 'indonesian', 'bahasa', 'idn', 'id', 'id-id'],
},
'uk-UA': {
code: 'uk-UA',
dateLocale: 'uk-UA',
labelKey: 'locale.ukUA',
aliases: ['uk', 'uk-ua'],
searchKeywords: ['ukrainian', 'українська', 'ukrayinska', 'uk', 'uk-ua'],
},
'sv-SE': {
code: 'sv-SE',
dateLocale: 'sv-SE',
labelKey: 'locale.svSE',
aliases: ['sv', 'sv-se'],
searchKeywords: ['swedish', 'svenska', 'sverige', 'sv', 'sv-se'],
},
'sk-SK': {
code: 'sk-SK',
dateLocale: 'sk-SK',
labelKey: 'locale.skSK',
aliases: ['sk', 'sk-sk'],
searchKeywords: ['slovak', 'slovencina', 'slovenčina', 'slovensko', 'sk', 'sk-sk'],
},
}
const APP_LOCALE_SET = new Set<AppLocale>(APP_LOCALES)
const LOCALE_DEFINITION_LOOKUP = new Map<AppLocale, LocaleDefinition>(
Object.values(LOCALE_DEFINITIONS).map((definition) => [definition.code, definition]),
)
const NORMALIZED_LOCALE_LOOKUP = new Map<string, AppLocale>()
for (const locale of APP_LOCALES) {
const definition = getLocaleDefinition(locale)
NORMALIZED_LOCALE_LOOKUP.set(locale.toLowerCase(), locale)
for (const alias of definition.aliases) {
NORMALIZED_LOCALE_LOOKUP.set(alias, locale)
}
}
const LOCALE_MODULES = import.meta.glob('./locales/*.json', { eager: true, import: 'default' }) as Record<string, TranslationCatalog>
const TRANSLATIONS: Partial<Record<AppLocale, Partial<Record<TranslationKey, string>>>> = buildTranslations()
export const APP_LOCALE_DEFINITIONS = APP_LOCALES.map((locale) => getLocaleDefinition(locale))
export { EN_TRANSLATIONS }
function buildTranslations() {
const translations: Partial<Record<AppLocale, Partial<Record<TranslationKey, string>>>> = {
en: EN_TRANSLATIONS,
}
for (const [path, catalog] of Object.entries(LOCALE_MODULES)) {
const match = path.match(/\/([^/]+)\.json$/)
if (!match) continue
const locale = normalizeLocaleCode(match[1])
if (!locale || locale === 'en') continue
Reflect.set(translations, locale, catalog)
}
return translations
}
function isAppLocale(value: string): value is AppLocale {
return APP_LOCALE_SET.has(value as AppLocale)
}
export function getLocaleDefinition(locale: AppLocale): LocaleDefinition {
const definition = LOCALE_DEFINITION_LOOKUP.get(locale)
if (definition) return definition
throw new Error(`Unknown locale: ${locale}`)
}
export function getLocaleDateLocale(locale: AppLocale): string {
return getLocaleDefinition(locale).dateLocale
}
export function interpolate(template: string, values: TranslationValues = {}): string {
const interpolationValues = new Map(Object.entries(values))
return template.replace(/\{(\w+)\}/g, (match, key) => {
const value = interpolationValues.get(key)
return value === undefined ? match : String(value)
})
}
function localizedInterpolationValues(locale: AppLocale, values?: TranslationValues): TranslationValues | undefined {
if (!values || locale === 'en' || values.plural === undefined) return values
return { ...values, plural: '' }
}
export function translate(locale: AppLocale, key: TranslationKey, values?: TranslationValues): string {
const catalog = Reflect.get(TRANSLATIONS, locale) as Partial<Record<TranslationKey, string>> | undefined
const template = Reflect.get(catalog ?? {}, key) as string | undefined
const fallbackTemplate = Reflect.get(EN_TRANSLATIONS, key) as string
return interpolate(template ?? fallbackTemplate, localizedInterpolationValues(locale, values))
}
export function createTranslator(locale: AppLocale = DEFAULT_APP_LOCALE) {
return (key: TranslationKey, values?: TranslationValues) => translate(locale, key, values)
}
function normalizeLocaleCode(value: string): AppLocale | null {
const normalized = value.trim().replaceAll('_', '-').toLowerCase()
if (!normalized) return null
const exactMatch = NORMALIZED_LOCALE_LOOKUP.get(normalized)
if (exactMatch) return exactMatch
const languageMatches = APP_LOCALES.filter((locale) => locale.toLowerCase().startsWith(`${normalized}-`))
return languageMatches.length === 1 ? languageMatches[0] : null
}
export function normalizeUiLanguagePreference(value: unknown): UiLanguagePreference | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
if (lower === SYSTEM_UI_LANGUAGE || lower === 'auto') return SYSTEM_UI_LANGUAGE
return normalizeLocaleCode(trimmed)
}
export function serializeUiLanguagePreference(value: unknown): AppLocale | null {
const normalized = normalizeUiLanguagePreference(value)
if (!normalized || normalized === SYSTEM_UI_LANGUAGE) return null
return normalized
}
export function getBrowserLanguagePreferences(): string[] {
if (typeof navigator === 'undefined') return []
const languages = Array.isArray(navigator.languages) ? navigator.languages : []
if (languages.length > 0) return [...languages]
return navigator.language ? [navigator.language] : []
}
export function resolveEffectiveLocale(
preference: unknown,
languagePreferences: readonly string[] = getBrowserLanguagePreferences(),
): AppLocale {
const normalizedPreference = normalizeUiLanguagePreference(preference)
if (normalizedPreference && normalizedPreference !== SYSTEM_UI_LANGUAGE) {
return normalizedPreference
}
for (const language of languagePreferences) {
const locale = normalizeLocaleCode(language)
if (locale) return locale
}
return DEFAULT_APP_LOCALE
}
export function localeDisplayName(locale: AppLocale, displayLocale: AppLocale = locale): string {
return translate(displayLocale, getLocaleDefinition(locale).labelKey)
}
export function localeSearchKeywords(locale: AppLocale): readonly string[] {
return getLocaleDefinition(locale).searchKeywords
}
export function hasLocaleCatalog(locale: AppLocale): boolean {
return locale === 'en' || Boolean(Reflect.get(TRANSLATIONS, locale))
}
export function localeCatalogLocales(): AppLocale[] {
return APP_LOCALES.filter((locale) => hasLocaleCatalog(locale))
}
export function isCanonicalAppLocale(value: string): value is AppLocale {
return isAppLocale(value)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
import {
EN_TRANSLATIONS,
translate,
type AppLocale,
type TranslationKey,
type TranslationValues,
} from './i18n'
const LOCALIZED_ERROR_PREFIX = 'tolaria:i18n-error:'
interface LocalizedStreamErrorRequest {
message: string
locale: AppLocale
}
interface LocalizedErrorPayload {
key: TranslationKey
values?: TranslationValues
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function translationValuesFrom(value: unknown): TranslationValues | undefined {
if (!isRecord(value)) return undefined
const values: TranslationValues = {}
for (const [key, item] of Object.entries(value)) {
if (typeof item === 'string' || typeof item === 'number') {
values[key] = item
}
}
return values
}
function isTranslationKey(value: unknown): value is TranslationKey {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(EN_TRANSLATIONS, value)
}
function parseLocalizedErrorPayload(message: string): LocalizedErrorPayload | null {
if (!message.startsWith(LOCALIZED_ERROR_PREFIX)) return null
let parsed: unknown
try {
parsed = JSON.parse(message.slice(LOCALIZED_ERROR_PREFIX.length))
} catch {
return null
}
if (!isRecord(parsed) || !isTranslationKey(parsed.key)) return null
return {
key: parsed.key,
values: translationValuesFrom(parsed.values),
}
}
export function localizedStreamErrorMessage({
message,
locale,
}: LocalizedStreamErrorRequest): string {
const payload = parseLocalizedErrorPayload(message)
return payload ? translate(locale, payload.key, payload.values) : message
}

View file

@ -0,0 +1,66 @@
export const nativeTextAssistanceDisabledAttributes = {
spellcheck: 'false',
autocomplete: 'off',
} as const
export const nativeTextAssistanceDisabledProps = {
spellCheck: false,
autoComplete: 'off',
} as const
export const rawEditorTextInputAttributes = {
...nativeTextAssistanceDisabledAttributes,
autocorrect: 'on',
autocapitalize: 'sentences',
} as const
const TEXT_ENTRY_SELECTOR = [
'textarea',
'[contenteditable="true"]',
'input:not([type])',
'input[type="email"]',
'input[type="number"]',
'input[type="password"]',
'input[type="search"]',
'input[type="tel"]',
'input[type="text"]',
'input[type="url"]',
].join(',')
function isElement(node: ParentNode): node is Element {
return typeof Element !== 'undefined' && node instanceof Element
}
function setNativeTextAssistanceDisabled(element: Element) {
for (const [attribute, value] of Object.entries(nativeTextAssistanceDisabledAttributes)) {
if (element.getAttribute(attribute) !== value) {
element.setAttribute(attribute, value)
}
}
}
export function disableNativeTextAssistance(root: ParentNode) {
if (isElement(root) && root.matches(TEXT_ENTRY_SELECTOR)) {
setNativeTextAssistanceDisabled(root)
}
root.querySelectorAll(TEXT_ENTRY_SELECTOR).forEach(setNativeTextAssistanceDisabled)
}
export function observeNativeTextAssistanceDisabled(root: ParentNode): () => void {
disableNativeTextAssistance(root)
if (typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => disableNativeTextAssistance(root))
observer.observe(root, {
attributeFilter: ['contenteditable'],
attributes: true,
childList: true,
subtree: true,
})
return () => observer.disconnect()
}

View file

@ -0,0 +1,248 @@
import type { AiAgentId } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
import { trackEvent } from './telemetry'
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
import type { DateDisplayFormat } from '../utils/dateDisplay'
import type { FilePreviewKind } from '../utils/filePreview'
import type { GitProviderId, NoteWidthMode } from '../types'
import type { CommitMessageDraftSource } from '../utils/commitMessageDraft'
import type { ThemeMode } from './themeMode'
type TrackedPreviewKind = FilePreviewKind | 'unsupported'
type FilePreviewAction = 'copy_deep_link' | 'copy_path' | 'open_external' | 'reveal'
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
type AiWorkspaceMode = 'docked' | 'side' | 'window'
type AiWorkspaceTitleSource = 'generated' | 'manual'
type NotePdfExportFailureReason = 'export_unavailable' | 'export_error'
type NotePdfExportSource = 'breadcrumb' | 'app_command' | 'note_list_context_menu'
type AnalyticsBoolean = boolean
type AiAgentResponseText = string
type AiAgentToolCount = number
type AiAgentResponseTextFlag = 'had_text' | 'had_partial_response'
type SheetFormulaFunctionName = string
const ALL_NOTES_VISIBILITY_CATEGORIES: ReadonlyArray<keyof AllNotesFileVisibility> = [
'pdfs',
'images',
'unsupported',
]
function trackedPreviewKind(previewKind: FilePreviewKind | null): TrackedPreviewKind {
return previewKind ?? 'unsupported'
}
function numericFlag(value: AnalyticsBoolean): number {
return value ? 1 : 0
}
function aiAgentResponsePayload(
agent: AiAgentId,
response: AiAgentResponseText,
toolCount: AiAgentToolCount,
textFlag: AiAgentResponseTextFlag,
) {
return {
agent,
[textFlag]: numericFlag(response.trim().length > 0),
tool_count: toolCount,
}
}
export function trackFilePreviewOpened(previewKind: FilePreviewKind | null): void {
trackEvent('file_preview_opened', {
preview_kind: trackedPreviewKind(previewKind),
})
}
export function trackFilePreviewAction(action: FilePreviewAction, previewKind: FilePreviewKind | null): void {
trackEvent('file_preview_action', {
action,
preview_kind: trackedPreviewKind(previewKind),
})
}
export function trackFilePreviewFailed(previewKind: FilePreviewKind): void {
trackEvent('file_preview_failed', { preview_kind: previewKind })
}
export function trackNotePdfExportStarted(source: NotePdfExportSource): void {
trackEvent('note_pdf_export_started', { source })
}
export function trackNotePdfExportFailed(
source: NotePdfExportSource,
reason: NotePdfExportFailureReason,
): void {
trackEvent('note_pdf_export_failed', { reason, source })
}
export function trackAllNotesVisibilityChanged(
previous: AllNotesFileVisibility,
next: AllNotesFileVisibility,
): void {
for (const category of ALL_NOTES_VISIBILITY_CATEGORIES) {
const previousValue = Reflect.get(previous, category) as boolean
const nextValue = Reflect.get(next, category) as boolean
if (previousValue === nextValue) continue
trackEvent('all_notes_visibility_changed', {
category,
enabled: numericFlag(nextValue),
})
}
}
export function trackAiFeaturesEnabledChanged(enabled: AnalyticsBoolean): void {
trackEvent('ai_features_visibility_changed', {
enabled: numericFlag(enabled),
})
}
export function trackGitFeaturesEnabledChanged(enabled: AnalyticsBoolean): void {
trackEvent('git_features_visibility_changed', {
enabled: numericFlag(enabled),
})
}
export function trackGitProviderChanged(provider: GitProviderId): void {
trackEvent('git_provider_changed', { provider })
}
export function trackGitWslDistroChanged(hasDistro: AnalyticsBoolean): void {
trackEvent('git_wsl_distro_changed', {
has_distro: numericFlag(hasDistro),
})
}
export function trackGitProviderTested(provider: GitProviderId, available: AnalyticsBoolean): void {
trackEvent('git_provider_tested', {
available: numericFlag(available),
provider,
})
}
export function trackCommitMessageGenerated(params: {
aiAttempted: AnalyticsBoolean
fileCount: number
source: CommitMessageDraftSource
}): void {
trackEvent('commit_message_generated', {
ai_attempted: numericFlag(params.aiAttempted),
file_count: params.fileCount,
source: params.source,
})
}
export function trackDefaultNoteWidthChanged(mode: NoteWidthMode): void {
trackEvent('note_width_default_changed', { mode })
}
export function trackDateDisplayFormatChanged(format: DateDisplayFormat): void {
trackEvent('date_display_format_changed', { format })
}
export function trackSidebarTypePluralizationChanged(enabled: AnalyticsBoolean): void {
trackEvent('sidebar_type_pluralization_changed', {
enabled: numericFlag(enabled),
})
}
export function trackThemeModeChanged(mode: ThemeMode): void {
trackEvent('theme_mode_changed', { mode })
}
export function trackInlineImageLightboxOpened(): void {
trackEvent('inline_image_lightbox_opened')
}
export function trackDatePropertyDirectEntrySaved(): void {
trackEvent('date_property_direct_entry_saved', { source: 'properties_panel' })
}
export function trackSheetEditorOpened(params: {
columnCount: number
hasMetadata: boolean
rowCount: number
}): void {
trackEvent('sheet_editor_opened', {
column_count: params.columnCount,
has_metadata: numericFlag(params.hasMetadata),
row_count: params.rowCount,
})
}
export function trackSheetFormulaAutocompleteUsed(functionName: SheetFormulaFunctionName): void {
trackEvent('sheet_formula_autocomplete_used', { function_name: functionName })
}
export function trackAiAgentMessageBlocked(agent: AiAgentId, reason: AgentBlockedReason): void {
trackEvent('ai_agent_message_blocked', { agent, reason })
}
export function trackAiAgentMessageSent(params: {
agent: AiAgentId
permissionMode: AiAgentPermissionMode
hasContext: boolean
referenceCount: number
historyMessageCount: number
}): void {
trackEvent('ai_agent_message_sent', {
agent: params.agent,
permission_mode: params.permissionMode,
has_context: numericFlag(params.hasContext),
reference_count: params.referenceCount,
history_message_count: params.historyMessageCount,
})
}
export function trackAiAgentResponseCompleted(
agent: AiAgentId,
response: AiAgentResponseText,
toolCount: AiAgentToolCount,
skipped: AnalyticsBoolean,
): void {
if (skipped) return
trackEvent('ai_agent_response_completed', aiAgentResponsePayload(agent, response, toolCount, 'had_text'))
}
export function trackAiAgentResponseFailed(
agent: AiAgentId,
response: AiAgentResponseText,
toolCount: AiAgentToolCount,
): void {
trackEvent('ai_agent_response_failed', {
...aiAgentResponsePayload(agent, response, toolCount, 'had_partial_response'),
error_kind: 'stream_error',
})
}
export function trackAiAgentResponseStopped(
agent: AiAgentId,
response: AiAgentResponseText,
toolCount: AiAgentToolCount,
): void {
trackEvent('ai_agent_response_stopped', aiAgentResponsePayload(agent, response, toolCount, 'had_partial_response'))
}
export function trackAiAgentPermissionModeChanged(agent: AiAgentId, permissionMode: AiAgentPermissionMode): void {
trackEvent('ai_agent_permission_mode_changed', {
agent,
permission_mode: permissionMode,
})
}
export function trackAiWorkspaceSidebarToggled(collapsed: AnalyticsBoolean, mode: AiWorkspaceMode): void {
trackEvent('ai_workspace_sidebar_toggled', {
collapsed: numericFlag(collapsed),
mode,
})
}
export function trackAiWorkspaceChatTitled(source: AiWorkspaceTitleSource): void {
trackEvent('ai_workspace_chat_titled', { source })
}
export function trackAiWorkspaceConversationDeleted(archived: AnalyticsBoolean): void {
trackEvent('ai_workspace_conversation_deleted', {
archived: numericFlag(archived),
})
}

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
normalizeReleaseChannel,
serializeReleaseChannel,
type ReleaseChannel,
} from './releaseChannel'
describe('releaseChannel', () => {
it('normalizes only alpha explicitly', () => {
expect(normalizeReleaseChannel('alpha')).toBe('alpha')
expect(normalizeReleaseChannel(' ALPHA ')).toBe('alpha')
})
it('falls back to stable for legacy or invalid values', () => {
expect(normalizeReleaseChannel(null)).toBe('stable')
expect(normalizeReleaseChannel('stable')).toBe('stable')
expect(normalizeReleaseChannel('beta')).toBe('stable')
expect(normalizeReleaseChannel('invalid')).toBe('stable')
})
it('serializes stable back to the persisted default shape', () => {
expect(serializeReleaseChannel('stable')).toBeNull()
expect(serializeReleaseChannel('alpha')).toBe('alpha')
})
it('roundtrips persisted values through the normalized channel model', () => {
const channels: Array<[string | null, ReleaseChannel]> = [
['alpha', 'alpha'],
['stable', 'stable'],
['beta', 'stable'],
[null, 'stable'],
]
for (const [persistedValue, expectedChannel] of channels) {
expect(normalizeReleaseChannel(persistedValue)).toBe(expectedChannel)
}
})
})

View file

@ -0,0 +1,13 @@
export type ReleaseChannel = 'alpha' | 'stable'
function cleanedReleaseChannel(value: string | null | undefined): string {
return value?.trim().toLowerCase() ?? ''
}
export function normalizeReleaseChannel(value: string | null | undefined): ReleaseChannel {
return cleanedReleaseChannel(value) === 'alpha' ? 'alpha' : 'stable'
}
export function serializeReleaseChannel(channel: ReleaseChannel): string | null {
return channel === 'alpha' ? 'alpha' : null
}

View file

@ -0,0 +1,6 @@
export const RUNTIME_STYLE_NONCE = 'tolaria-runtime-style'
export const RUNTIME_STYLE_NONCE_SOURCE = `'nonce-${RUNTIME_STYLE_NONCE}'`
export function getRuntimeStyleNonce() {
return RUNTIME_STYLE_NONCE
}

View file

@ -0,0 +1,124 @@
export const PATH_REDACTION = '[redacted-path]'
export const TOKEN_REDACTION = '[redacted-token]'
const LEADING_TOKEN_WRAPPERS = new Set(['"', "'", '`', '(', '[', '{'])
const SENSITIVE_KEYS = ['token', 'secret', 'password', 'authorization', 'cookie', 'session']
const TOKEN_PREFIXES = ['ghp_', 'gho_', 'ghr_', 'ghs_', 'ghu_', 'github_pat_', 'sk-', 'xoxa-', 'xoxb-', 'xoxp-', 'xoxr-', 'xoxs-']
const TRAILING_TOKEN_WRAPPERS = new Set(['"', "'", '`', ')', ']', '}', '.', ',', ';'])
const WHITESPACE = new Set([' ', '\t', '\n', '\r'])
interface RedactTextInput {
redactTokens?: boolean
text: string
}
interface RedactTokenInput {
redactTokens: boolean
token: string
}
interface TextValueInput {
value: string
}
interface TokenInput {
token: string
}
interface SegmentInput {
segment?: string
}
interface TokenParts {
core: string
prefix: string
suffix: string
}
export function redactPathText({ text }: RedactTextInput): string {
return redactTextSegments({ text })
}
export function sanitizeDiagnosticText({ text }: RedactTextInput): string {
return collapseWhitespace({ text: redactTextSegments({ text, redactTokens: true }) }).trim()
}
export function isSensitiveDiagnosticKey({ text }: RedactTextInput): boolean {
const lowerText = text.toLowerCase()
return SENSITIVE_KEYS.some((sensitiveKey) => lowerText.includes(sensitiveKey))
}
function redactTextSegments({ text, redactTokens = false }: RedactTextInput): string {
let redacted = ''
let token = ''
for (const char of text) {
if (WHITESPACE.has(char)) {
redacted += redactToken({ token, redactTokens }) + char
token = ''
} else {
token += char
}
}
return redacted + redactToken({ token, redactTokens })
}
function redactToken({ token, redactTokens }: RedactTokenInput): string {
if (!token) return token
const parts = tokenParts({ token })
if (isAbsolutePath({ value: parts.core })) return `${parts.prefix}${PATH_REDACTION}${parts.suffix}`
if (redactTokens && isTokenLike({ value: parts.core })) return `${parts.prefix}${TOKEN_REDACTION}${parts.suffix}`
return token
}
function tokenParts({ token }: TokenInput): TokenParts {
let start = 0
let end = token.length
while (start < end && LEADING_TOKEN_WRAPPERS.has(token.at(start) ?? '')) start += 1
while (end > start && TRAILING_TOKEN_WRAPPERS.has(token.at(end - 1) ?? '')) end -= 1
return {
prefix: token.slice(0, start),
core: token.slice(start, end),
suffix: token.slice(end),
}
}
function isAbsolutePath({ value }: TextValueInput): boolean {
return isUnixAbsolutePath({ value }) || isWindowsAbsolutePath({ value })
}
function isUnixAbsolutePath({ value }: TextValueInput): boolean {
return value.startsWith('/') && value.split('/').filter(Boolean).length >= 2
}
function isWindowsAbsolutePath({ value }: TextValueInput): boolean {
const segments = value.split('\\').filter(Boolean)
return segments.length >= 3 && isWindowsDriveSegment({ segment: segments[0] })
}
function isWindowsDriveSegment({ segment }: SegmentInput): boolean {
const letter = segment?.at(0)
return segment?.length === 2
&& letter !== undefined
&& letter.toLowerCase() !== letter.toUpperCase()
&& segment.at(1) === ':'
}
function isTokenLike({ value }: TextValueInput): boolean {
return TOKEN_PREFIXES.some((prefix) => value.startsWith(prefix))
}
function collapseWhitespace({ text }: RedactTextInput): string {
let collapsed = ''
let pendingWhitespace = false
for (const char of text) {
if (WHITESPACE.has(char)) {
pendingWhitespace = true
} else {
if (pendingWhitespace && collapsed.length > 0) collapsed += ' '
collapsed += char
pendingWhitespace = false
}
}
return collapsed
}

View file

@ -0,0 +1,286 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
const sentryMocks = vi.hoisted(() => ({
close: vi.fn(),
init: vi.fn(),
setTag: vi.fn(),
setUser: vi.fn(),
}))
vi.mock('@sentry/react', () => sentryMocks)
import {
_scrubPathsForTest as scrubPaths,
initSentry,
isFeatureEnabled,
setReleaseChannel,
teardownSentry,
trackEvent,
} from './telemetry'
import { retainWhiteboardPlatformPermissionGuard } from '../utils/whiteboardPlatformPermissionRejection'
afterEach(() => {
teardownSentry()
vi.unstubAllEnvs()
sentryMocks.close.mockClear()
sentryMocks.init.mockClear()
sentryMocks.setTag.mockClear()
sentryMocks.setUser.mockClear()
})
describe('telemetry scrubPaths', () => {
it('redacts macOS absolute paths', () => {
expect(scrubPaths('Error in /Users/luca/Laputa/note.md')).toBe(
'Error in [redacted-path]'
)
})
it('redacts Linux absolute paths', () => {
expect(scrubPaths('Error in /home/user/vault/note.md')).toBe(
'Error in [redacted-path]'
)
})
it('redacts Windows paths', () => {
expect(scrubPaths('Error in C:\\Users\\luca\\docs\\file.md')).toBe(
'Error in [redacted-path]'
)
})
it('leaves non-path strings untouched', () => {
expect(scrubPaths('Something went wrong')).toBe('Something went wrong')
})
it('redacts multiple paths in one string', () => {
const input = 'Failed copying /a/b/c to /x/y/z'
expect(scrubPaths(input)).toBe('Failed copying [redacted-path] to [redacted-path]')
})
})
describe('trackEvent', () => {
it('does not throw when PostHog is not initialized', () => {
expect(() => trackEvent('test_event', { count: 1 })).not.toThrow()
})
it('accepts event name with no properties', () => {
expect(() => trackEvent('note_created')).not.toThrow()
})
it('accepts event name with string and number properties', () => {
expect(() => trackEvent('note_created', { has_type: 1, creation_path: 'cmd_n' })).not.toThrow()
})
})
describe('initSentry', () => {
function initSentryBeforeSend(): (event: Record<string, unknown>, hint?: { originalException?: unknown }) => unknown {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
initSentry('anonymous-user')
const beforeSend = sentryMocks.init.mock.calls[0]?.[0]?.beforeSend
expect(beforeSend).toEqual(expect.any(Function))
return beforeSend as (event: Record<string, unknown>, hint?: { originalException?: unknown }) => unknown
}
it.each([
['stable builds', '2026.4.23', '2026.4.23', 'stable'],
['alpha builds', '2026.4.28-alpha.7', undefined, 'prerelease'],
['local builds', '0.1.0', undefined, 'internal'],
])('sets release metadata for %s', (_name, buildVersion, sentryRelease, releaseKind) => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
vi.stubEnv('VITE_SENTRY_RELEASE', buildVersion)
initSentry('anonymous-user')
expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({
dsn: 'https://public@example.ingest.sentry.io/123456',
release: sentryRelease,
}))
expect(sentryMocks.setUser).toHaveBeenCalledWith({ id: 'anonymous-user' })
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.build_version', buildVersion)
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.release_kind', releaseKind)
})
it('drops active whiteboard platform permission rejections before sending them to Sentry', () => {
const beforeSend = initSentryBeforeSend()
const releaseGuard = retainWhiteboardPlatformPermissionGuard()
const rejectionEvent = {
exception: {
values: [{
type: 'NotAllowedError',
value: 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
}],
},
}
const hintedEvent = { message: 'Unhandled promise rejection' }
try {
expect(beforeSend(rejectionEvent)).toBeNull()
expect(beforeSend(hintedEvent, {
originalException: {
name: 'NotAllowedError',
message: 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
},
})).toBeNull()
} finally {
releaseGuard()
}
})
it('keeps non-whiteboard Sentry events while the whiteboard guard is active', () => {
const beforeSend = initSentryBeforeSend()
const releaseGuard = retainWhiteboardPlatformPermissionGuard()
const event = {
exception: {
values: [{
type: 'Error',
value: 'Save failed',
}],
},
}
try {
expect(beforeSend(event)).toBe(event)
} finally {
releaseGuard()
}
})
it('keeps platform permission rejections when no whiteboard guard is active', () => {
const beforeSend = initSentryBeforeSend()
const event = {
exception: {
values: [{
type: 'NotAllowedError',
value: 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
}],
},
}
expect(beforeSend(event)).toBe(event)
})
it('drops stale Tauri listener cleanup errors before sending them to Sentry', () => {
const beforeSend = initSentryBeforeSend()
const staleListenerEvent = {
exception: {
values: [{
type: 'TypeError',
value: "undefined is not an object (evaluating 'listeners[eventId].handlerId')",
}],
},
}
const messageOnlyEvent = {
message: "TypeError: undefined is not an object (evaluating 'listeners[eventId].handlerId')",
}
const unrelatedTypeErrorEvent = {
exception: {
values: [{
type: 'TypeError',
value: "undefined is not an object (evaluating 'note.title')",
}],
},
}
expect(beforeSend(staleListenerEvent)).toBeNull()
expect(beforeSend(messageOnlyEvent)).toBeNull()
expect(beforeSend(unrelatedTypeErrorEvent)).toBe(unrelatedTypeErrorEvent)
})
it('drops stale BlockNote block-reference errors before sending them to Sentry', () => {
const beforeSend = initSentryBeforeSend()
const staleBlockEvent = {
exception: {
values: [{
type: 'Error',
value: 'Block with ID 15e8eb56-0947-4d4a-85c2-1611a864465a not found',
}],
},
}
const messageOnlyEvent = {
message: 'Error: Block with ID 15e8eb56-0947-4d4a-85c2-1611a864465a not found',
}
const joinedStableEvent = {
exception: {
values: [{
type: 'Error',
value: [
'Error: Block with ID 669f337a-dee2-4d92-b5cb-9a4e9828ecf9 not found',
'Block with ID 669f337a-dee2-4d92-b5cb-9a4e9828ecf9 not found',
'fIt(tauri://localhost/assets/App-BmzAl58b.js)',
'Error: Block with ID 1dcc3557-09d6-4d0d-b513-4fb07b9f451f not found',
].join(' | '),
}],
},
}
const hintedEvent = {
message: 'Script error.',
}
const unrelatedNotFoundEvent = {
exception: {
values: [{
type: 'Error',
value: 'Vault entry with ID 15e8eb56-0947-4d4a-85c2-1611a864465a not found',
}],
},
}
expect(beforeSend(staleBlockEvent)).toBeNull()
expect(beforeSend(messageOnlyEvent)).toBeNull()
expect(beforeSend(joinedStableEvent)).toBeNull()
expect(beforeSend(hintedEvent, {
originalException: new Error('Block with ID 15e8eb56-0947-4d4a-85c2-1611a864465a not found'),
})).toBeNull()
expect(beforeSend(unrelatedNotFoundEvent)).toBe(unrelatedNotFoundEvent)
})
it('drops browser ResizeObserver loop notifications before sending them to Sentry', () => {
const beforeSend = initSentryBeforeSend()
const loopLimitEvent = {
exception: {
values: [{
type: 'Error',
value: 'ResizeObserver loop limit exceeded',
}],
},
}
const undeliveredEvent = {
message: 'ResizeObserver loop completed with undelivered notifications.',
}
const hintedEvent = {
message: 'Script error.',
}
const unrelatedObserverEvent = {
exception: {
values: [{
type: 'Error',
value: 'ResizeObserver callback failed while measuring the editor',
}],
},
}
expect(beforeSend(loopLimitEvent)).toBeNull()
expect(beforeSend(undeliveredEvent)).toBeNull()
expect(beforeSend(hintedEvent, {
originalException: new Error('ResizeObserver loop limit exceeded'),
})).toBeNull()
expect(beforeSend(unrelatedObserverEvent)).toBe(unrelatedObserverEvent)
})
})
describe('isFeatureEnabled', () => {
it('returns true for alpha channel regardless of flag state', () => {
setReleaseChannel('alpha')
expect(isFeatureEnabled('any_flag')).toBe(true)
expect(isFeatureEnabled('nonexistent_flag')).toBe(true)
})
it('returns false for stable channel when PostHog is not initialized', () => {
setReleaseChannel('stable')
expect(isFeatureEnabled('some_flag')).toBe(false)
})
it('returns false for beta channel when PostHog is not initialized', () => {
setReleaseChannel('beta')
expect(isFeatureEnabled('some_flag')).toBe(false)
})
})

View file

@ -0,0 +1,213 @@
import * as Sentry from '@sentry/react'
import { resolveFrontendTelemetryConfig } from './telemetryConfig'
import { redactPathText } from './sensitiveTextRedaction'
import {
hasActiveWhiteboardPlatformPermissionGuard,
isWhiteboardPlatformPermissionRejection,
} from '../utils/whiteboardPlatformPermissionRejection'
type SensitiveTelemetryText = string
type AnonymousTelemetryId = string
type ReleaseChannel = string
type FeatureFlagKey = string
type ProductAnalyticsEventName = string
type ProductAnalyticsProperties = Record<string, string | number>
const STALE_TAURI_LISTENER_CLEANUP_SIGNATURE = "listeners[eventId].handlerId"
const BLOCKNOTE_STALE_BLOCK_REFERENCE_PATTERN = /\bBlock with ID [^|\n]+? not found\b/
const RESIZE_OBSERVER_LOOP_MESSAGES = [
'ResizeObserver loop completed with undelivered notifications',
'ResizeObserver loop limit exceeded',
] as const
function scrubPaths(input: SensitiveTelemetryText): string {
return redactPathText({ text: input })
}
function isStaleTauriListenerCleanupText(value: string | undefined): boolean {
return value?.includes(STALE_TAURI_LISTENER_CLEANUP_SIGNATURE) ?? false
}
function isBlockNoteStaleBlockReferenceText(value: string | undefined): boolean {
return value ? BLOCKNOTE_STALE_BLOCK_REFERENCE_PATTERN.test(value) : false
}
function isResizeObserverLoopText(value: string | undefined): boolean {
return value
? RESIZE_OBSERVER_LOOP_MESSAGES.some((message) => value.includes(message))
: false
}
function errorText(value: unknown): string | undefined {
if (!value) return undefined
if (value instanceof Error) return `${value.name}: ${value.message}`
if (typeof value === 'string') return value
if (typeof value !== 'object') return undefined
const maybeError = value as { message?: unknown; name?: unknown }
const message = typeof maybeError.message === 'string' ? maybeError.message : undefined
const name = typeof maybeError.name === 'string' ? maybeError.name : undefined
return [name, message].filter(Boolean).join(': ') || undefined
}
function shouldDropWhiteboardPlatformPermissionEvent(
event: Sentry.ErrorEvent,
hint?: Sentry.EventHint,
): boolean {
if (!hasActiveWhiteboardPlatformPermissionGuard()) return false
if (isWhiteboardPlatformPermissionRejection(hint?.originalException)) return true
return (event.exception?.values ?? []).some((exception) =>
isWhiteboardPlatformPermissionRejection({
message: exception.value ?? '',
name: exception.type ?? '',
}))
}
function shouldDropStaleTauriListenerCleanupEvent(
event: Sentry.ErrorEvent,
hint?: Sentry.EventHint,
): boolean {
if (isStaleTauriListenerCleanupText(errorText(hint?.originalException))) return true
if (isStaleTauriListenerCleanupText(event.message)) return true
return (event.exception?.values ?? []).some((exception) =>
isStaleTauriListenerCleanupText(exception.value))
}
function shouldDropBlockNoteStaleBlockReferenceEvent(
event: Sentry.ErrorEvent,
hint?: Sentry.EventHint,
): boolean {
if (isBlockNoteStaleBlockReferenceText(errorText(hint?.originalException))) return true
if (isBlockNoteStaleBlockReferenceText(event.message)) return true
return (event.exception?.values ?? []).some((exception) =>
isBlockNoteStaleBlockReferenceText(exception.value))
}
function shouldDropResizeObserverLoopEvent(
event: Sentry.ErrorEvent,
hint?: Sentry.EventHint,
): boolean {
if (isResizeObserverLoopText(errorText(hint?.originalException))) return true
if (isResizeObserverLoopText(event.message)) return true
return (event.exception?.values ?? []).some((exception) =>
isResizeObserverLoopText(exception.value))
}
function shouldDropSentryEvent(event: Sentry.ErrorEvent, hint?: Sentry.EventHint): boolean {
return shouldDropWhiteboardPlatformPermissionEvent(event, hint)
|| shouldDropStaleTauriListenerCleanupEvent(event, hint)
|| shouldDropBlockNoteStaleBlockReferenceEvent(event, hint)
|| shouldDropResizeObserverLoopEvent(event, hint)
}
function scrubEventMessage(event: Sentry.ErrorEvent): void {
if (event.message) event.message = scrubPaths(event.message)
}
function scrubExceptionValues(event: Sentry.ErrorEvent): void {
for (const ex of event.exception?.values ?? []) {
if (ex.value) ex.value = scrubPaths(ex.value)
}
}
function scrubBreadcrumbMessages(event: Sentry.ErrorEvent): void {
for (const breadcrumb of event.breadcrumbs ?? []) {
if (breadcrumb.message) breadcrumb.message = scrubPaths(breadcrumb.message)
}
}
function scrubSentryEvent(event: Sentry.ErrorEvent, hint?: Sentry.EventHint): Sentry.ErrorEvent | null {
if (shouldDropSentryEvent(event, hint)) return null
scrubEventMessage(event)
scrubExceptionValues(event)
scrubBreadcrumbMessages(event)
return event
}
let sentryInitialized = false
let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: AnonymousTelemetryId): void {
if (sentryInitialized) return
const { sentryDsn, sentryBuildVersion, sentryRelease } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
dsn: sentryDsn,
release: sentryRelease || undefined,
sendDefaultPii: false,
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
if (sentryBuildVersion) {
const releaseKind = sentryRelease
? 'stable'
: sentryBuildVersion.includes('-') ? 'prerelease' : 'internal'
Sentry.setTag('tolaria.build_version', sentryBuildVersion)
Sentry.setTag('tolaria.release_kind', releaseKind)
}
sentryInitialized = true
}
export function teardownSentry(): void {
if (!sentryInitialized) return
Sentry.close()
sentryInitialized = false
}
export async function initPostHog(anonymousId: AnonymousTelemetryId, releaseChannel?: ReleaseChannel): Promise<void> {
if (posthogInstance) return
const { posthogKey, posthogHost } = resolveFrontendTelemetryConfig()
if (!posthogKey || !posthogHost) return
const posthog = (await import('posthog-js')).default
posthog.init(posthogKey, {
api_host: posthogHost,
autocapture: false,
capture_pageview: false,
persistence: 'memory',
disable_session_recording: true,
})
posthog.identify(anonymousId, releaseChannel ? { release_channel: releaseChannel } : undefined)
posthogInstance = posthog
}
export function teardownPostHog(): void {
if (!posthogInstance) return
posthogInstance.opt_out_capturing()
posthogInstance.reset()
posthogInstance = null
}
export function updatePostHogIdentify(releaseChannel: ReleaseChannel): void {
posthogInstance?.identify(undefined, { release_channel: releaseChannel })
}
/** Hardcoded defaults for first launch with no network (PostHog cache empty). */
const FEATURE_DEFAULTS: Record<string, boolean> = {}
let currentReleaseChannel: ReleaseChannel = 'stable'
export function setReleaseChannel(channel: ReleaseChannel): void {
currentReleaseChannel = channel
}
export function isFeatureEnabled(flagKey: FeatureFlagKey): boolean {
if (currentReleaseChannel === 'alpha') return true
return posthogInstance?.isFeatureEnabled(flagKey) ?? (Reflect.get(FEATURE_DEFAULTS, flagKey) as boolean | undefined) ?? false
}
export function trackEvent(name: ProductAnalyticsEventName, properties?: ProductAnalyticsProperties): void {
posthogInstance?.capture(name, properties)
}
export { scrubPaths as _scrubPathsForTest }

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import {
_defaultPostHogHostForTest as defaultPostHogHost,
resolveFrontendTelemetryConfig,
sanitizeTelemetryEnvValue,
} from './telemetryConfig'
function resolveConfig(overrides: {
VITE_SENTRY_DSN?: string
VITE_SENTRY_RELEASE?: string
VITE_POSTHOG_KEY?: string
VITE_POSTHOG_HOST?: string
} = {}) {
return resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
VITE_SENTRY_RELEASE: '2026.4.23',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
...overrides,
})
}
describe('sanitizeTelemetryEnvValue', () => {
it('trims surrounding whitespace', () => {
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
})
it('unwraps matching quotes after trimming', () => {
expect(sanitizeTelemetryEnvValue(' "value" ')).toBe('value')
expect(sanitizeTelemetryEnvValue(" 'value' ")).toBe('value')
})
it('returns an empty string for blank input', () => {
expect(sanitizeTelemetryEnvValue(' ')).toBe('')
expect(sanitizeTelemetryEnvValue(undefined)).toBe('')
})
})
describe('resolveFrontendTelemetryConfig', () => {
it.each([
{
name: 'keeps valid telemetry values after sanitizing them',
overrides: {
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
VITE_SENTRY_RELEASE: " '2026.4.23' ",
VITE_POSTHOG_KEY: " 'phc_test_key' ",
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
},
},
{
name: 'adds https to scheme-less DSNs and PostHog hosts',
overrides: {
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
},
},
])('$name', ({ overrides, expected }) => {
expect(resolveConfig(overrides)).toEqual(expected)
})
it('uses the default PostHog host when one is not configured', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: undefined }).posthogHost).toBe(defaultPostHogHost)
})
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
expect(resolveConfig({ VITE_SENTRY_DSN: 'not a dsn' }).sentryDsn).toBe('')
})
it('drops placeholder Sentry release values instead of grouping them', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: 'false' }).sentryRelease).toBe('')
})
it('keeps stable calendar versions as Sentry releases', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28' }).sentryRelease).toBe('2026.4.28')
})
it('drops prerelease versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28-alpha.7' })).toMatchObject({
sentryBuildVersion: '2026.4.28-alpha.7',
sentryRelease: '',
})
})
it('drops local development versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '0.1.0' })).toMatchObject({
sentryBuildVersion: '0.1.0',
sentryRelease: '',
})
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
})
it('drops placeholder telemetry hosts that would create broken startup requests', () => {
expect(resolveConfig({
VITE_SENTRY_DSN: 'https://public@false/123456',
VITE_POSTHOG_HOST: 'false',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
})
})
it('drops single-label telemetry hosts but keeps localhost for dev', () => {
expect(resolveConfig({
VITE_SENTRY_DSN: 'https://public@le/123456',
VITE_POSTHOG_HOST: 'https://le',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
})
expect(resolveConfig({
VITE_SENTRY_DSN: 'http://public@localhost:9000/123456',
VITE_POSTHOG_HOST: 'http://localhost:8010',
})).toEqual({
sentryDsn: 'http://public@localhost:9000/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'http://localhost:8010',
})
})
})

View file

@ -0,0 +1,157 @@
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
const IPV6_ADDRESS_CHARS = new Set('0123456789abcdefABCDEF:')
const DISALLOWED_TELEMETRY_VALUES = new Set([
'false',
'true',
'null',
'undefined',
'none',
'disabled',
])
type TelemetryEnv = {
VITE_SENTRY_DSN?: string
VITE_SENTRY_RELEASE?: string
VITE_POSTHOG_KEY?: string
VITE_POSTHOG_HOST?: string
}
interface HostnameInput {
hostname: string
}
interface HostSegmentInput {
segment: string
}
interface TelemetryValueInput {
value: string
}
export type FrontendTelemetryConfig = {
sentryDsn: string
sentryBuildVersion: string
sentryRelease: string
posthogKey: string
posthogHost: string | null
}
function unwrapMatchingQuotes({ value }: TelemetryValueInput): string {
if (value.length < 2) return value
const first = value[0]
const last = value[value.length - 1]
if (first !== last) return value
if (first !== '"' && first !== "'") return value
return value.slice(1, -1).trim()
}
export function sanitizeTelemetryEnvValue(value: string | undefined): string {
if (!value) return ''
const trimmed = value.trim()
if (!trimmed) return ''
return unwrapMatchingQuotes({ value: trimmed })
}
function isHttpUrl({ value }: TelemetryValueInput): boolean {
try {
const url = new URL(value)
return (url.protocol === 'http:' || url.protocol === 'https:')
&& isAllowedTelemetryHostname({ hostname: url.hostname })
} catch {
return false
}
}
function normalizeHostname({ hostname }: HostnameInput): string {
const normalized = hostname.trim().replace(/\.$/, '').toLowerCase()
if (normalized.startsWith('[') && normalized.endsWith(']')) {
return normalized.slice(1, -1)
}
return normalized
}
function isIpAddress({ hostname }: HostnameInput): boolean {
return isIpv4Address({ hostname }) || isIpv6LikeAddress({ hostname })
}
function isIpv4Address({ hostname }: HostnameInput): boolean {
const segments = hostname.split('.')
return segments.length === 4 && segments.every((segment) => isIpv4Segment({ segment }))
}
function isIpv4Segment({ segment }: HostSegmentInput): boolean {
const value = Number(segment)
return segment.length > 0
&& Array.from(segment).every((char) => char >= '0' && char <= '9')
&& Number.isInteger(value)
&& value >= 0
&& value <= 255
}
function isIpv6LikeAddress({ hostname }: HostnameInput): boolean {
return hostname.includes(':') && Array.from(hostname).every((char) => IPV6_ADDRESS_CHARS.has(char))
}
function isAllowedTelemetryHostname({ hostname }: HostnameInput): boolean {
const normalized = normalizeHostname({ hostname })
if (!normalized || DISALLOWED_TELEMETRY_VALUES.has(normalized)) return false
if (normalized === 'localhost') return true
return normalized.includes('.') || isIpAddress({ hostname: normalized })
}
function normalizeHttpLikeValue({ value }: TelemetryValueInput): string {
if (!value) return ''
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
return `https://${value}`
}
function normalizeSentryDsn({ value }: TelemetryValueInput): string {
const normalized = normalizeHttpLikeValue({ value })
return isHttpUrl({ value: normalized }) ? normalized : ''
}
function normalizeSentryRelease({ value }: TelemetryValueInput): string {
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(value)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
const validDate = date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
return validDate ? value : ''
}
function normalizePostHogHost({ value }: TelemetryValueInput): string | null {
if (!value) return DEFAULT_POSTHOG_HOST
const normalized = normalizeHttpLikeValue({ value })
return isHttpUrl({ value: normalized }) ? normalized : null
}
export function resolveFrontendTelemetryConfig(
env: TelemetryEnv = import.meta.env as TelemetryEnv,
): FrontendTelemetryConfig {
const sentryDsn = normalizeSentryDsn({
value: sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
})
const sanitizedSentryVersion = sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE)
const sentryBuildVersion = DISALLOWED_TELEMETRY_VALUES.has(sanitizedSentryVersion.toLowerCase())
? ''
: sanitizedSentryVersion
const sentryRelease = normalizeSentryRelease({ value: sentryBuildVersion })
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost({
value: sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
})
return { sentryDsn, sentryBuildVersion, sentryRelease, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View file

@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyStoredThemeMode,
applyThemeModeToDocument,
LEGACY_THEME_MODE_STORAGE_KEY,
normalizeThemeMode,
readStoredThemeMode,
resolveThemeMode,
THEME_MODE_STORAGE_KEY,
writeStoredThemeMode,
} from './themeMode'
function makeStorage(initial: Record<string, string> = {}): Storage {
const values = new Map(Object.entries(initial))
return {
get length() { return values.size },
clear: vi.fn(() => values.clear()),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => { values.delete(key) }),
setItem: vi.fn((key: string, value: string) => { values.set(key, value) }),
}
}
describe('themeMode', () => {
it('normalizes only supported theme modes', () => {
expect(normalizeThemeMode('light')).toBe('light')
expect(normalizeThemeMode('dark')).toBe('dark')
expect(normalizeThemeMode('system')).toBe('system')
expect(resolveThemeMode('system', makeMatchMedia(true))).toBe('dark')
expect(resolveThemeMode('system', makeMatchMedia(false))).toBe('light')
expect(resolveThemeMode('sepia')).toBe('light')
})
it('reads and writes the current storage key', () => {
const storage = makeStorage()
writeStoredThemeMode(storage, 'system')
expect(readStoredThemeMode(storage)).toBe('system')
expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'system')
})
it('migrates the legacy storage key', () => {
const storage = makeStorage({ [LEGACY_THEME_MODE_STORAGE_KEY]: 'dark' })
expect(readStoredThemeMode(storage)).toBe('dark')
expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark')
})
it('applies theme attributes and shadcn dark class', () => {
applyThemeModeToDocument(document, 'dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
applyThemeModeToDocument(document, 'light')
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
expect(document.documentElement).not.toHaveClass('dark')
})
it('bootstraps stored theme mode onto the document', () => {
const storage = makeStorage({ [THEME_MODE_STORAGE_KEY]: 'dark' })
expect(applyStoredThemeMode(document, storage)).toBe('dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
})
it('bootstraps system mode to the current OS appearance without storing system in data-theme', () => {
const storage = makeStorage({ [THEME_MODE_STORAGE_KEY]: 'system' })
expect(applyStoredThemeMode(document, storage, makeMatchMedia(true))).toBe('dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
})
})
function makeMatchMedia(matches: boolean): Window['matchMedia'] {
return ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
})) as Window['matchMedia']
}

View file

@ -0,0 +1,105 @@
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
export const THEME_MODE_STORAGE_KEY = APP_STORAGE_KEYS.theme
export const LEGACY_THEME_MODE_STORAGE_KEY = LEGACY_APP_STORAGE_KEYS.theme
export const DEFAULT_THEME_MODE = 'light'
export const SYSTEM_THEME_MODE = 'system'
export const SYSTEM_THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)'
const RESOLVED_THEME_MODES = new Set(['light', 'dark'])
const THEME_MODES = new Set([...RESOLVED_THEME_MODES, SYSTEM_THEME_MODE])
export type ResolvedThemeMode = 'light' | 'dark'
export type ThemeMode = ResolvedThemeMode | typeof SYSTEM_THEME_MODE
type ThemeStorage = Pick<Storage, 'getItem' | 'setItem'>
type ThemeDocument = Pick<Document, 'documentElement'>
type ThemeMatchMedia = Window['matchMedia']
export function normalizeThemeMode(value: unknown): ThemeMode | null {
return typeof value === 'string' && THEME_MODES.has(value) ? value as ThemeMode : null
}
export function normalizeResolvedThemeMode(value: unknown): ResolvedThemeMode | null {
const mode = normalizeThemeMode(value)
return mode === 'light' || mode === 'dark' ? mode : null
}
function resolveMatchMedia(matchMedia?: ThemeMatchMedia): ThemeMatchMedia | null {
if (matchMedia) return matchMedia
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null
return window.matchMedia.bind(window)
}
export function resolveSystemThemeMode(matchMedia?: ThemeMatchMedia): ResolvedThemeMode {
const resolvedMatchMedia = resolveMatchMedia(matchMedia)
if (!resolvedMatchMedia) return DEFAULT_THEME_MODE
try {
return resolvedMatchMedia(SYSTEM_THEME_MEDIA_QUERY).matches ? 'dark' : 'light'
} catch {
return DEFAULT_THEME_MODE
}
}
export function resolveThemeMode(value: unknown, matchMedia?: ThemeMatchMedia): ResolvedThemeMode {
const mode = normalizeThemeMode(value)
if (mode === SYSTEM_THEME_MODE) return resolveSystemThemeMode(matchMedia)
return mode ?? DEFAULT_THEME_MODE
}
function safeGetThemeMode(storage: ThemeStorage, key: string): ThemeMode | null {
try {
return normalizeThemeMode(storage.getItem(key))
} catch {
return null
}
}
function safeSetThemeMode(storage: ThemeStorage, key: string, mode: ThemeMode): void {
try {
storage.setItem(key, mode)
} catch {
// Storage can be unavailable in restricted browser contexts.
}
}
export function readStoredThemeMode(storage: ThemeStorage): ThemeMode | null {
const storedMode = safeGetThemeMode(storage, THEME_MODE_STORAGE_KEY)
if (storedMode) return storedMode
const legacyMode = safeGetThemeMode(storage, LEGACY_THEME_MODE_STORAGE_KEY)
if (!legacyMode) return null
safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, legacyMode)
return legacyMode
}
export function writeStoredThemeMode(storage: ThemeStorage, mode: ThemeMode): void {
safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, mode)
}
export function applyThemeModeToDocument(documentObject: ThemeDocument, mode: ResolvedThemeMode): void {
const root = documentObject.documentElement
root.setAttribute('data-theme', mode)
root.classList.toggle('dark', mode === 'dark')
}
export function applyThemeSelectionToDocument(
documentObject: ThemeDocument,
mode: ThemeMode,
matchMedia?: ThemeMatchMedia,
): ResolvedThemeMode {
const resolvedMode = resolveThemeMode(mode, matchMedia)
applyThemeModeToDocument(documentObject, resolvedMode)
return resolvedMode
}
export function applyStoredThemeMode(
documentObject: ThemeDocument,
storage: ThemeStorage,
matchMedia?: ThemeMatchMedia,
): ResolvedThemeMode {
const mode = readStoredThemeMode(storage) ?? DEFAULT_THEME_MODE
return applyThemeSelectionToDocument(documentObject, mode, matchMedia)
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkspaceAiGuidanceRefreshKey,
createCheckingWorkspaceAiGuidanceStatus,
getWorkspaceAiGuidanceSummary,
normalizeWorkspaceAiGuidanceStatus,
workspaceAiGuidanceNeedsRestore,
workspaceAiGuidanceUsesCustomFiles,
} from './workspaceAiGuidance'
import type { VaultEntry } from '../types'
function makeEntry(filename: string, overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: `/vault/${filename}`,
filename,
title: filename.replace(/\.md$/, ''),
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 10,
createdAt: 5,
fileSize: 20,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
describe('workspaceAiGuidance helpers', () => {
it('starts in checking state', () => {
expect(createCheckingWorkspaceAiGuidanceStatus()).toEqual({
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
})
})
it('normalizes raw backend payloads', () => {
expect(normalizeWorkspaceAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
it('detects restoreable and custom states', () => {
const restoreable = normalizeWorkspaceAiGuidanceStatus({
agents_state: 'missing',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: true,
})
const custom = normalizeWorkspaceAiGuidanceStatus({
agents_state: 'custom',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(workspaceAiGuidanceNeedsRestore(restoreable)).toBe(true)
expect(getWorkspaceAiGuidanceSummary(restoreable)).toBe('光湖人格体唤醒指南缺失或损坏')
expect(workspaceAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getWorkspaceAiGuidanceSummary(custom)).toBe('Using custom AGENTS.md')
})
it('summarizes optional Gemini guidance states', () => {
const missing = normalizeWorkspaceAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'missing',
can_restore: true,
})
const custom = normalizeWorkspaceAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'custom',
can_restore: false,
})
expect(workspaceAiGuidanceNeedsRestore(missing)).toBe(true)
expect(getWorkspaceAiGuidanceSummary(missing)).toBe('Gemini guidance can be created')
expect(workspaceAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getWorkspaceAiGuidanceSummary(custom)).toBe('Using custom GEMINI.md')
})
it('builds a refresh key from AGENTS and CLAUDE entries only', () => {
const key = buildWorkspaceAiGuidanceRefreshKey([
makeEntry('alpha.md'),
makeEntry('CLAUDE.md', { modifiedAt: 20, fileSize: 30 }),
makeEntry('GEMINI.md', { modifiedAt: 25, fileSize: 35 }),
makeEntry('AGENTS.md', { modifiedAt: 15, fileSize: 40 }),
])
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30|/vault/GEMINI.md:25:35')
})
})

View file

@ -0,0 +1,131 @@
import type { VaultEntry } from '../types'
export type WorkspaceAiGuidanceFileState = 'checking' | 'managed' | 'missing' | 'broken' | 'custom'
export interface WorkspaceAiGuidanceStatus {
agentsState: WorkspaceAiGuidanceFileState
claudeState: WorkspaceAiGuidanceFileState
geminiState: WorkspaceAiGuidanceFileState
canRestore: boolean
}
type RawWorkspaceAiGuidanceStatus = Partial<{
agents_state: WorkspaceAiGuidanceFileState | null
claude_state: WorkspaceAiGuidanceFileState | null
gemini_state: WorkspaceAiGuidanceFileState | null
can_restore: boolean | null
}>
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])
export function createCheckingWorkspaceAiGuidanceStatus(): WorkspaceAiGuidanceStatus {
return {
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
}
}
function normalizeFileState(value: string | null | undefined): WorkspaceAiGuidanceFileState {
switch (value) {
case 'managed':
case 'missing':
case 'broken':
case 'custom':
return value
default:
return 'checking'
}
}
export function normalizeWorkspaceAiGuidanceStatus(
payload: RawWorkspaceAiGuidanceStatus | null | undefined,
): WorkspaceAiGuidanceStatus {
return {
agentsState: normalizeFileState(payload?.agents_state),
claudeState: normalizeFileState(payload?.claude_state),
geminiState: normalizeFileState(payload?.gemini_state),
canRestore: payload?.can_restore === true,
}
}
export function isWorkspaceAiGuidanceStatusChecking(status: WorkspaceAiGuidanceStatus): boolean {
return status.agentsState === 'checking'
|| status.claudeState === 'checking'
|| status.geminiState === 'checking'
}
export function workspaceAiGuidanceNeedsRestore(status: WorkspaceAiGuidanceStatus): boolean {
if (!status.canRestore || isWorkspaceAiGuidanceStatusChecking(status)) return false
return status.agentsState === 'missing'
|| status.agentsState === 'broken'
|| status.claudeState === 'missing'
|| status.claudeState === 'broken'
|| status.geminiState === 'missing'
|| status.geminiState === 'broken'
}
export function workspaceAiGuidanceUsesCustomFiles(status: WorkspaceAiGuidanceStatus): boolean {
return status.agentsState === 'custom'
|| status.claudeState === 'custom'
|| status.geminiState === 'custom'
}
function isMissingOrBroken(state: WorkspaceAiGuidanceFileState): boolean {
return state === 'missing' || state === 'broken'
}
function formatGuidanceFileList(names: string[]): string {
if (names.length < 2) return names.join('')
if (names.length === 2) return `${names[0]} and ${names[1]}`
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
}
function getBrokenGuidanceSummary(status: WorkspaceAiGuidanceStatus): string | null {
if (isMissingOrBroken(status.agentsState)) {
return '光湖人格体唤醒指南缺失或损坏'
}
if (isMissingOrBroken(status.claudeState)) {
return 'Claude compatibility shim missing or broken'
}
if (status.geminiState === 'missing') {
return 'Gemini guidance can be created'
}
if (status.geminiState === 'broken') {
return 'Gemini guidance missing or broken'
}
return null
}
function getCustomGuidanceSummary(status: WorkspaceAiGuidanceStatus): string | null {
const customNames = [
status.agentsState === 'custom' ? 'AGENTS.md' : null,
status.claudeState === 'custom' ? 'CLAUDE.md' : null,
status.geminiState === 'custom' ? 'GEMINI.md' : null,
].filter((name): name is string => name !== null)
if (customNames.length > 1) {
return `Custom ${formatGuidanceFileList(customNames)} active`
}
if (status.agentsState === 'custom') return 'Using custom AGENTS.md'
if (status.claudeState === 'custom') return 'Using custom CLAUDE.md'
if (status.geminiState === 'custom') return 'Using custom GEMINI.md'
return null
}
export function getWorkspaceAiGuidanceSummary(status: WorkspaceAiGuidanceStatus): string {
if (isWorkspaceAiGuidanceStatusChecking(status)) return 'Checking vault guidance…'
const brokenSummary = getBrokenGuidanceSummary(status)
if (brokenSummary) return brokenSummary
const customSummary = getCustomGuidanceSummary(status)
if (customSummary) return customSummary
return 'HoloLake Era guidance ready'
}
export function buildWorkspaceAiGuidanceRefreshKey(entries: VaultEntry[]): string {
return entries
.filter((entry) => GUIDANCE_FILENAMES.has(entry.filename))
.map((entry) => `${entry.path}:${entry.modifiedAt ?? 0}:${entry.fileSize}`)
.sort()
.join('|')
}