feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
20
product-source/hololake-platform/src/lib/agentDocsPath.ts
Normal file
20
product-source/hololake-platform/src/lib/agentDocsPath.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
136
product-source/hololake-platform/src/lib/aiAgentConversation.ts
Normal file
136
product-source/hololake-platform/src/lib/aiAgentConversation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 === ';'
|
||||
}
|
||||
131
product-source/hololake-platform/src/lib/aiAgentMessageState.ts
Normal file
131
product-source/hololake-platform/src/lib/aiAgentMessageState.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
435
product-source/hololake-platform/src/lib/aiAgentSession.test.ts
Normal file
435
product-source/hololake-platform/src/lib/aiAgentSession.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
271
product-source/hololake-platform/src/lib/aiAgentSession.ts
Normal file
271
product-source/hololake-platform/src/lib/aiAgentSession.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
68
product-source/hololake-platform/src/lib/aiAgents.test.ts
Normal file
68
product-source/hololake-platform/src/lib/aiAgents.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
151
product-source/hololake-platform/src/lib/aiAgents.ts
Normal file
151
product-source/hololake-platform/src/lib/aiAgents.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
11
product-source/hololake-platform/src/lib/aiFeatures.test.ts
Normal file
11
product-source/hololake-platform/src/lib/aiFeatures.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
5
product-source/hololake-platform/src/lib/aiFeatures.ts
Normal file
5
product-source/hololake-platform/src/lib/aiFeatures.ts
Normal 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
|
||||
}
|
||||
139
product-source/hololake-platform/src/lib/aiTargets.test.ts
Normal file
139
product-source/hololake-platform/src/lib/aiTargets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
216
product-source/hololake-platform/src/lib/aiTargets.ts
Normal file
216
product-source/hololake-platform/src/lib/aiTargets.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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({})
|
||||
}
|
||||
|
|
@ -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]]'] })
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
13
product-source/hololake-platform/src/lib/appIconTheme.ts
Normal file
13
product-source/hololake-platform/src/lib/appIconTheme.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
55
product-source/hololake-platform/src/lib/appUpdater.ts
Normal file
55
product-source/hololake-platform/src/lib/appUpdater.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
})
|
||||
})
|
||||
|
|
@ -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?.()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
157
product-source/hololake-platform/src/lib/feedbackDiagnostics.ts
Normal file
157
product-source/hololake-platform/src/lib/feedbackDiagnostics.ts
Normal 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 = []
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
}
|
||||
5
product-source/hololake-platform/src/lib/gitSettings.ts
Normal file
5
product-source/hololake-platform/src/lib/gitSettings.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Settings } from '../types'
|
||||
|
||||
export function areGitFeaturesEnabled(settings: Pick<Settings, 'git_enabled'>): boolean {
|
||||
return settings.git_enabled !== false
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
205
product-source/hololake-platform/src/lib/guanghuLivingSystem.ts
Normal file
205
product-source/hololake-platform/src/lib/guanghuLivingSystem.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
180
product-source/hololake-platform/src/lib/guanghuRouter.ts
Normal file
180
product-source/hololake-platform/src/lib/guanghuRouter.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
55
product-source/hololake-platform/src/lib/guanghuTheme.ts
Normal file
55
product-source/hololake-platform/src/lib/guanghuTheme.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
145
product-source/hololake-platform/src/lib/guanghuWorldGraph.ts
Normal file
145
product-source/hololake-platform/src/lib/guanghuWorldGraph.ts
Normal 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]
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
85
product-source/hololake-platform/src/lib/i18n.test.ts
Normal file
85
product-source/hololake-platform/src/lib/i18n.test.ts
Normal 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('訪達')
|
||||
}
|
||||
})
|
||||
})
|
||||
359
product-source/hololake-platform/src/lib/i18n.ts
Normal file
359
product-source/hololake-platform/src/lib/i18n.ts
Normal 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)
|
||||
}
|
||||
1125
product-source/hololake-platform/src/lib/locales/be-BY.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/be-BY.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/be-Latn.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/be-Latn.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/de-DE.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/de-DE.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/en.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/en.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/es-419.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/es-419.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/es-ES.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/es-ES.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/fr-FR.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/fr-FR.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/id-ID.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/id-ID.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/it-IT.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/it-IT.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/ja-JP.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/ja-JP.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/ko-KR.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/ko-KR.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/pl-PL.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/pl-PL.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/pt-BR.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/pt-BR.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/pt-PT.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/pt-PT.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/ru-RU.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/ru-RU.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/sk-SK.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/sk-SK.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/sv-SE.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/sv-SE.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/uk-UA.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/uk-UA.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/vi.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/vi.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/zh-CN.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/zh-CN.json
Normal file
File diff suppressed because it is too large
Load diff
1125
product-source/hololake-platform/src/lib/locales/zh-TW.json
Normal file
1125
product-source/hololake-platform/src/lib/locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
248
product-source/hololake-platform/src/lib/productAnalytics.ts
Normal file
248
product-source/hololake-platform/src/lib/productAnalytics.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
13
product-source/hololake-platform/src/lib/releaseChannel.ts
Normal file
13
product-source/hololake-platform/src/lib/releaseChannel.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
286
product-source/hololake-platform/src/lib/telemetry.test.ts
Normal file
286
product-source/hololake-platform/src/lib/telemetry.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
213
product-source/hololake-platform/src/lib/telemetry.ts
Normal file
213
product-source/hololake-platform/src/lib/telemetry.ts
Normal 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 }
|
||||
146
product-source/hololake-platform/src/lib/telemetryConfig.test.ts
Normal file
146
product-source/hololake-platform/src/lib/telemetryConfig.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
157
product-source/hololake-platform/src/lib/telemetryConfig.ts
Normal file
157
product-source/hololake-platform/src/lib/telemetryConfig.ts
Normal 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 }
|
||||
89
product-source/hololake-platform/src/lib/themeMode.test.ts
Normal file
89
product-source/hololake-platform/src/lib/themeMode.test.ts
Normal 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']
|
||||
}
|
||||
105
product-source/hololake-platform/src/lib/themeMode.ts
Normal file
105
product-source/hololake-platform/src/lib/themeMode.ts
Normal 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)
|
||||
}
|
||||
6
product-source/hololake-platform/src/lib/utils.ts
Normal file
6
product-source/hololake-platform/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
131
product-source/hololake-platform/src/lib/workspaceAiGuidance.ts
Normal file
131
product-source/hololake-platform/src/lib/workspaceAiGuidance.ts
Normal 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('|')
|
||||
}
|
||||
Loading…
Reference in a new issue