From 0bd28f96e07839385f33d0598c1d1332872c9a7b Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 01:54:04 +0800 Subject: [PATCH 001/206] fix(flow-chat): restore context usage display after session hydration Startup or opening a historical session left session.currentTokenUsage undefined because the only writer was the TokenUsageUpdated event fired after a model response. The ModelSelector then hid the context percentage (tokenPercentage > 0 guard) and the tooltip omitted the last-request context line (current <= 0 guard). Backfill currentTokenUsage from the last completed dialog turn's persisted tokenUsage during hydrate commits in loadSessionHistory and refreshPeerSessionSnapshot. The backfill is idempotent (keeps any live value) and skipped for ACP sessions. The exact value is still overwritten by the next TokenUsageUpdated event. --- .../src/flow_chat/store/FlowChatStore.test.ts | 106 ++++++++++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 18 +++ .../flow_chat/utils/tokenUsageDisplay.test.ts | 84 +++++++++++++- .../src/flow_chat/utils/tokenUsageDisplay.ts | 33 +++++- 4 files changed, 239 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 65756d8280..2c73a2717e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -5227,4 +5227,110 @@ describe('FlowChatStore historical session hydration state', () => { }); expect(apiMocks.loadSessionTurnWindow).toHaveBeenCalledTimes(1); }); + + it('backfills currentTokenUsage from the last completed turn after hydration', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [ + { + ...createPersistedTurn(0), + endTime: 2, + tokenUsage: { + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + timestamp: 2, + }, + }, + { + ...createPersistedTurn(1), + endTime: 4, + tokenUsage: { + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + timestamp: 4, + }, + }, + ], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toMatchObject({ + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + }); + }); + + it('keeps an existing currentTokenUsage when hydrating historical turns', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [ + { + ...createPersistedTurn(0), + endTime: 2, + tokenUsage: { + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + timestamp: 2, + }, + }, + ], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + currentTokenUsage: { + inputTokens: 999, + outputTokens: 1, + totalTokens: 1000, + timestamp: 5, + }, + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toMatchObject({ + inputTokens: 999, + outputTokens: 1, + totalTokens: 1000, + }); + }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 625281c79e..f673c8eee9 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -56,6 +56,7 @@ import { } from '../utils/sessionMetadata'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; +import { deriveContextUsageFromTurns } from '../utils/tokenUsageDisplay'; import { deriveSessionTitleState, deriveSessionTitleStateFromMetadata, @@ -106,6 +107,13 @@ function firstNonEmptyString(...values: unknown[]): string | undefined { return undefined; } +function isAcpSessionForContextUsage(session: Session): boolean { + return Boolean( + session.mode?.startsWith('acp:') + || session.config.agentType?.startsWith('acp:'), + ); +} + function persistedSessionRemoteScope( metadata: { remoteConnectionId?: unknown; @@ -6878,6 +6886,11 @@ export class FlowChatStore { restored.session.lastUserDialogAgentType || session.lastUserDialogMode, lastSubmittedMode: restored.session.lastSubmittedAgentType ?? session.lastSubmittedMode, + currentTokenUsage: + session.currentTokenUsage + ?? (!isAcpSessionForContextUsage(session) + ? deriveContextUsageFromTurns(mergedTurns) + : undefined), }); applied = true; @@ -7330,6 +7343,11 @@ export class FlowChatStore { lastUserDialogMode: restoredLastUserDialogMode, lastSubmittedMode: restoredSessionInfo?.lastSubmittedAgentType ?? session.lastSubmittedMode, + currentTokenUsage: + session.currentTokenUsage + ?? (!isAcpSessionForContextUsage(session) + ? deriveContextUsageFromTurns(dialogTurns) + : undefined), }; const newSessions = new Map(prev.sessions); diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts index 73c2691e72..80726d0e4b 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { Session, TokenUsage } from '../types/flow-chat'; +import type { DialogTurn, Session, TokenUsage } from '../types/flow-chat'; import { buildContextUsageTooltip, buildModelRoundUsageMeta, + deriveContextUsageFromTurns, formatCompactTokenCount, getSessionContextUsageDisplay, } from './tokenUsageDisplay'; @@ -144,3 +145,84 @@ describe('tokenUsageDisplay', () => { expect(formatCompactTokenCount(4000)).toBe('4K'); }); }); + +describe('deriveContextUsageFromTurns', () => { + const makeTurn = ( + overrides: Partial & { + status: DialogTurn['status']; + tokenUsage?: TokenUsage; + }, + ): DialogTurn => ({ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'hello', + timestamp: 1000, + }, + modelRounds: [], + status: 'completed', + startTime: 1000, + ...overrides, + }); + + const usage = (inputTokens: number): TokenUsage => ({ + inputTokens, + outputTokens: 100, + totalTokens: inputTokens + 100, + timestamp: 2000, + }); + + it('returns the last completed turn usage', () => { + const turns = [ + makeTurn({ id: 'turn-1', status: 'completed', tokenUsage: usage(1000) }), + makeTurn({ id: 'turn-2', status: 'completed', tokenUsage: usage(2000) }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toEqual(usage(2000)); + }); + + it('skips unfinished turns and falls back to the last completed turn', () => { + const turns = [ + makeTurn({ id: 'turn-1', status: 'completed', tokenUsage: usage(1000) }), + makeTurn({ id: 'turn-2', status: 'processing', tokenUsage: usage(500) }), + makeTurn({ id: 'turn-3', status: 'pending', tokenUsage: usage(300) }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toEqual(usage(1000)); + }); + + it('skips turns without usage and returns the last completed one that has it', () => { + const turns = [ + makeTurn({ id: 'turn-1', status: 'completed' }), + makeTurn({ id: 'turn-2', status: 'error', tokenUsage: usage(2500) }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toEqual(usage(2500)); + }); + + it('skips completed turns with zero or invalid input tokens', () => { + const turns = [ + makeTurn({ + id: 'turn-1', + status: 'completed', + tokenUsage: { inputTokens: 0, totalTokens: 0, timestamp: 2000 }, + }), + makeTurn({ + id: 'turn-2', + status: 'cancelled', + tokenUsage: { inputTokens: 420, totalTokens: 500, timestamp: 3000 }, + }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toMatchObject({ inputTokens: 420 }); + }); + + it('returns undefined for empty input or when no completed turn has usage', () => { + expect(deriveContextUsageFromTurns([])).toBeUndefined(); + expect(deriveContextUsageFromTurns(undefined)).toBeUndefined(); + expect(deriveContextUsageFromTurns([ + makeTurn({ id: 'turn-1', status: 'processing' }), + ])).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts index f77ef19b00..9f8ade61a5 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts @@ -1,4 +1,4 @@ -import type { Session, TokenUsage } from '../types/flow-chat'; +import type { DialogTurn, Session, TokenUsage } from '../types/flow-chat'; export const DEFAULT_MAX_CONTEXT_TOKENS = 128128; @@ -35,6 +35,37 @@ function formatCompactNumber(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, ''); } +/** + * Derive the last completed turn's token usage as a context-usage approximation. + * + * Used to restore `session.currentTokenUsage` when a session is hydrated from + * persisted history (startup or opening a historical session): the exact value + * is only reported by the backend after the next model response. + */ +export function deriveContextUsageFromTurns(turns: DialogTurn[] | undefined): TokenUsage | undefined { + if (!turns) { + return undefined; + } + + for (let i = turns.length - 1; i >= 0; i--) { + const turn = turns[i]; + const usage = turn.tokenUsage; + if (!usage) { + continue; + } + if ( + turn.status === 'completed' + || turn.status === 'error' + || turn.status === 'cancelled' + ) { + if (typeof usage.inputTokens === 'number' && usage.inputTokens > 0) { + return usage; + } + } + } + return undefined; +} + export function getSessionContextUsageDisplay(session?: Session): ContextUsageDisplay { if (!session) { return { From 973f6a913260519d1d4e2d5088a884a995bce45e Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 03:03:47 +0800 Subject: [PATCH 002/206] fix(flow-chat): persist exact last request usage for startup display The hydration backfill reused the last completed dialog turn's accumulated token usage, which sums input tokens across every model round of that turn. Long agentic sessions therefore showed an absurd context usage (e.g. 8.9M tokens) right after startup. Store the exact last request usage in session metadata (customMetadata.lastRequestTokenUsage, via the existing UI metadata whitelist) on every TokenUsageUpdated for non-ACP sessions, restore it during metadata hydration, and restrict the turn-based fallback to single-round turns where the accumulated value equals a single request. --- .../src/runtime/session_application.rs | 7 +- .../flow-chat-manager/EventHandlerModule.ts | 12 +++ .../flow-chat-manager/PersistenceModule.ts | 80 +++++++++++++++++ .../src/flow_chat/store/FlowChatStore.test.ts | 87 +++++++++++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 26 ++++++ .../flow_chat/utils/tokenUsageDisplay.test.ts | 26 +++++- .../src/flow_chat/utils/tokenUsageDisplay.ts | 17 ++-- 7 files changed, 247 insertions(+), 8 deletions(-) diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 27bfbb11bb..17997b7ac5 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -36,7 +36,12 @@ use bitfun_runtime_ports::{AgentContextReloadRequest, SessionTurnWindowRequest}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -const UI_CUSTOM_METADATA_KEYS: [&str; 3] = ["titleSource", "titleKey", "titleParams"]; +const UI_CUSTOM_METADATA_KEYS: [&str; 4] = [ + "titleSource", + "titleKey", + "titleParams", + "lastRequestTokenUsage", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 972d59dac3..b3a22dc8c8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -57,6 +57,7 @@ const pendingImageAnalysisTurns = new Map(); import { debouncedSaveDialogTurn, immediateSaveDialogTurn, + persistLastRequestTokenUsage, saveDialogTurnToDisk, cleanupSaveState, } from './PersistenceModule'; @@ -2136,6 +2137,17 @@ function handleTokenUsageUpdate(context: FlowChatContext, event: any): void { totalTokens }, turnId); + // Persist the exact last request usage so the context display survives a + // restart. Skip ACP sessions: their display is driven by + // currentAcpContextUsage instead. + if (!session.mode?.startsWith('acp:') && !session.config.agentType?.startsWith('acp:')) { + persistLastRequestTokenUsage(context, sessionId, { + inputTokens, + outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined, + totalTokens, + }); + } + if (maxContextTokens !== undefined && maxContextTokens !== null) { store.updateSessionMaxContextTokens(sessionId, maxContextTokens); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index fc6bffd29e..daac1d78e4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -604,3 +604,83 @@ export async function touchSessionActivity( log.debug('Failed to touch session activity', { sessionId, error }); } } + +const lastRequestTokenUsageDebouncers = new Map< + string, + ReturnType +>(); + +/** + * Persist the exact last model request usage into session metadata so the + * input-box context display can be restored after an app restart. + * + * Only the session-level last request value is stored; the dialog turn usage + * stays accumulated per turn and must not be reused as a single-request + * approximation. The write is trailing-throttled because agentic sessions + * can emit one TokenUsageUpdated per model round. + */ +export function persistLastRequestTokenUsage( + context: FlowChatContext, + sessionId: string, + usage: { inputTokens: number; outputTokens?: number; totalTokens: number }, +): void { + const existingTimer = lastRequestTokenUsageDebouncers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + lastRequestTokenUsageDebouncers.delete(sessionId); + void persistLastRequestTokenUsageNow(context, sessionId, usage).catch(error => { + log.warn('Failed to persist last request token usage', { sessionId, error }); + }); + }, COALESCED_IMMEDIATE_SAVE_DELAY_MS); + lastRequestTokenUsageDebouncers.set(sessionId, timer); +} + +async function persistLastRequestTokenUsageNow( + context: FlowChatContext, + sessionId: string, + usage: { inputTokens: number; outputTokens?: number; totalTokens: number }, +): Promise { + const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); + + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) return; + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; + + const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); + + let existingMetadata: any = null; + try { + existingMetadata = await sessionAPI.loadSessionMetadata( + sessionId, + workspacePath, + session.remoteConnectionId, + session.remoteSshHost + ); + } catch { + // Metadata may not exist yet for a fresh session; the patch below still works. + } + + const metadata = { + ...existingMetadata, + sessionId, + customMetadata: { + ...(existingMetadata?.customMetadata ?? {}), + lastRequestTokenUsage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + timestamp: Date.now(), + }, + }, + }; + + await sessionAPI.saveSessionMetadata( + metadata, + workspacePath, + ['titleMetadata'], + session.remoteConnectionId, + session.remoteSshHost + ); +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 2c73a2717e..1e45a2ff51 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -5242,6 +5242,17 @@ describe('FlowChatStore historical session hydration state', () => { turns: [ { ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], endTime: 2, tokenUsage: { inputTokens: 1000, @@ -5252,6 +5263,17 @@ describe('FlowChatStore historical session hydration state', () => { }, { ...createPersistedTurn(1), + modelRounds: [{ + id: 'round-1', + turnId: 'turn-1', + roundIndex: 0, + timestamp: 3, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 3, + status: 'completed', + }], endTime: 4, tokenUsage: { inputTokens: 2400, @@ -5297,6 +5319,17 @@ describe('FlowChatStore historical session hydration state', () => { turns: [ { ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], endTime: 2, tokenUsage: { inputTokens: 2400, @@ -5333,4 +5366,58 @@ describe('FlowChatStore historical session hydration state', () => { totalTokens: 1000, }); }); + + it('restores the exact last request token usage from persisted metadata', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + customMetadata: { + lastRequestTokenUsage: { + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + timestamp: 21, + }, + }, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toMatchObject({ + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + }); + }); + + it('ignores invalid persisted last request token usage', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + customMetadata: { + lastRequestTokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + timestamp: 21, + }, + }, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toBeUndefined(); + }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index f673c8eee9..bf3f9108b8 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -114,6 +114,24 @@ function isAcpSessionForContextUsage(session: Session): boolean { ); } +function deriveRestoredCurrentTokenUsage(value: unknown): TokenUsage | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const record = value as Record; + const inputTokens = record.inputTokens; + if (typeof inputTokens !== 'number' || !Number.isFinite(inputTokens) || inputTokens <= 0) { + return undefined; + } + const totalTokens = record.totalTokens; + return { + inputTokens, + outputTokens: typeof record.outputTokens === 'number' ? record.outputTokens : undefined, + totalTokens: typeof totalTokens === 'number' && Number.isFinite(totalTokens) ? totalTokens : inputTokens, + timestamp: typeof record.timestamp === 'number' ? record.timestamp : Date.now(), + }; +} + function persistedSessionRemoteScope( metadata: { remoteConnectionId?: unknown; @@ -6251,6 +6269,9 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, ); + const restoredCurrentTokenUsage = deriveRestoredCurrentTokenUsage( + metadata.customMetadata?.lastRequestTokenUsage, + ); this.setState(prev => { if (surfaceGeneration !== this.surfaceGeneration) { @@ -6292,6 +6313,7 @@ export class FlowChatStore { historyState: 'metadata-only', todos: (metadata as any).todos || [], maxContextTokens, + currentTokenUsage: restoredCurrentTokenUsage, mode: validatedAgentType, lastUserDialogMode: metadata.lastUserDialogAgentType, lastSubmittedMode: metadata.lastSubmittedAgentType, @@ -6629,6 +6651,9 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, ); + const restoredCurrentTokenUsage = deriveRestoredCurrentTokenUsage( + metadata.customMetadata?.lastRequestTokenUsage, + ); this.setState(prev => { if (prev.sessions.has(metadata.sessionId)) { @@ -6667,6 +6692,7 @@ export class FlowChatStore { historyState: 'metadata-only', todos: (metadata as any).todos || [], maxContextTokens, + currentTokenUsage: restoredCurrentTokenUsage, mode: validatedAgentType, lastUserDialogMode: metadata.lastUserDialogAgentType, lastSubmittedMode: metadata.lastSubmittedAgentType, diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts index 80726d0e4b..7c50c66f06 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts @@ -160,7 +160,7 @@ describe('deriveContextUsageFromTurns', () => { content: 'hello', timestamp: 1000, }, - modelRounds: [], + modelRounds: [{ id: 'round-1' }], status: 'completed', startTime: 1000, ...overrides, @@ -218,11 +218,33 @@ describe('deriveContextUsageFromTurns', () => { expect(deriveContextUsageFromTurns(turns)).toMatchObject({ inputTokens: 420 }); }); - it('returns undefined for empty input or when no completed turn has usage', () => { + it('skips multi-round turns because accumulated usage would overestimate context', () => { + const turns = [ + makeTurn({ id: 'turn-1', status: 'completed', tokenUsage: usage(1000) }), + makeTurn({ + id: 'turn-2', + status: 'completed', + modelRounds: [{ id: 'round-1' }, { id: 'round-2' }], + tokenUsage: usage(8_900_000), + }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toEqual(usage(1000)); + }); + + it('returns undefined for empty input or when no completed single-round turn has usage', () => { expect(deriveContextUsageFromTurns([])).toBeUndefined(); expect(deriveContextUsageFromTurns(undefined)).toBeUndefined(); expect(deriveContextUsageFromTurns([ makeTurn({ id: 'turn-1', status: 'processing' }), ])).toBeUndefined(); + expect(deriveContextUsageFromTurns([ + makeTurn({ + id: 'turn-1', + status: 'completed', + modelRounds: [{ id: 'round-1' }, { id: 'round-2' }], + tokenUsage: usage(9000), + }), + ])).toBeUndefined(); }); }); diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts index 9f8ade61a5..3f4b9961f2 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts @@ -36,11 +36,14 @@ function formatCompactNumber(value: number): string { } /** - * Derive the last completed turn's token usage as a context-usage approximation. + * Derive the last completed single-round turn's token usage as a + * context-usage approximation. * - * Used to restore `session.currentTokenUsage` when a session is hydrated from - * persisted history (startup or opening a historical session): the exact value - * is only reported by the backend after the next model response. + * Used as a fallback to restore `session.currentTokenUsage` when a session is + * hydrated from persisted history and no exact last-request usage was stored + * in session metadata. Only single-round turns are used: dialog turn usage + * accumulates across model rounds, so a multi-round turn's input total would + * badly overestimate the current context. */ export function deriveContextUsageFromTurns(turns: DialogTurn[] | undefined): TokenUsage | undefined { if (!turns) { @@ -58,7 +61,11 @@ export function deriveContextUsageFromTurns(turns: DialogTurn[] | undefined): To || turn.status === 'error' || turn.status === 'cancelled' ) { - if (typeof usage.inputTokens === 'number' && usage.inputTokens > 0) { + if ( + turn.modelRounds.length === 1 + && typeof usage.inputTokens === 'number' + && usage.inputTokens > 0 + ) { return usage; } } From 707c0993e4c827320ce8df7df6236aa3fdad3c50 Mon Sep 17 00:00:00 2001 From: qq_40662086 Date: Thu, 6 Aug 2026 16:03:01 +0800 Subject: [PATCH 003/206] future: add startup --agent --model --session --continue flags --- src/apps/cli/src/account.rs | 3 +- src/apps/cli/src/main.rs | 122 ++++++++++++++++++++++++++++- src/apps/cli/src/modes/chat.rs | 9 +++ src/apps/cli/src/modes/chat/run.rs | 16 ++++ src/apps/cli/src/ui/startup.rs | 10 +++ 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index b70e68e694..b1b7dc1852 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -1077,7 +1077,8 @@ async fn handle_relay_auth_error( } let mut current_context = account_context.write().await; if current_context - .as_ref().is_none_or(|context| context.session.token != expected_token) + .as_ref() + .is_none_or(|context| context.session.token != expected_token) { tracing::debug!("Ignoring auth error cleanup for a replaced account"); return; diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 1b403aa630..4d31fce77b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -107,6 +107,22 @@ struct Cli { /// Automation, desktop, and remote modes remain unchanged. #[arg(long, verbatim_doc_comment)] shared: bool, + + /// Continue the most recent session (skip startup page) + #[arg(long = "continue", conflicts_with = "session")] + continue_last: bool, + + /// Open a specific session by ID (or "last" for the most recent) + #[arg(long, conflicts_with = "continue_last")] + session: Option, + + /// Specify the model ID for this session + #[arg(long)] + model: Option, + + /// Specify the agent type for this session + #[arg(long)] + agent: Option, } fn shared_tui_requested(shared: bool, command: &Option) -> Result { @@ -877,6 +893,9 @@ async fn run_interactive( default_agent: String, _workspace_str: String, shared: bool, + agent_override: Option, + model_id: Option, + session_override: Option, ) -> Result<()> { use ui::startup::{StartupPage, StartupResult}; @@ -987,14 +1006,48 @@ async fn run_interactive( account_sync::start_settings_sync_loop(); } + // Resolve agent override: validate against the agent registry AFTER core services init + let effective_agent = if let Some(ref override_val) = agent_override { + match resolve_agent_override(override_val).await { + Ok(valid_id) => valid_id, + Err(warning) => { + eprintln!("{warning}"); + default_agent.clone() + } + } + } else { + default_agent.clone() + }; + + // If --continue or --session was given, skip the startup page and go directly + // to chat with the resolved session. + if let Some(ref session_spec) = session_override { + let restore_session_id = resolve_startup_session_override(&agent, session_spec).await?; + + let mut chat_mode = ChatMode::new(config, effective_agent, workspace, agent, compatibility) + .with_restore_session(restore_session_id); + if let Some(mid) = model_id { + chat_mode = chat_mode.with_model(mid); + } + let chat_result = chat_mode.run(Some(terminal)); + + if !shared { + shutdown_mcp_servers().await; + } + let _exit_reason = chat_result?; + println!("Goodbye!"); + return Ok(()); + } + // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( config, Arc::clone(&agent), compatibility.clone(), - default_agent, + effective_agent, workspace.clone(), ); + startup_page.set_model_override(model_id.clone()); let startup_result = startup_page.run(&mut terminal)?; if let StartupResult::Exit = startup_result { @@ -1031,6 +1084,9 @@ async fn run_interactive( if let Some(prompt) = initial_prompt { chat_mode = chat_mode.with_initial_prompt(prompt); } + if let Some(mid) = model_id { + chat_mode = chat_mode.with_model(mid); + } let chat_result = chat_mode.run(Some(terminal)); // 6. Cleanup, including fatal event-stream exits. @@ -1043,6 +1099,40 @@ async fn run_interactive( Ok(()) } +/// Resolve a `--session` / `--continue` override to a concrete session ID. +/// "last" (or empty for --continue) resolves to the most recent session. +async fn resolve_startup_session_override( + agent: &Arc, + session_spec: &str, +) -> Result { + if session_spec == "last" || session_spec.is_empty() { + let sessions = agent.list_sessions().await?; + return sessions + .first() + .map(|s| s.session_id.clone()) + .ok_or_else(|| anyhow!("No history sessions for current project")); + } + bitfun_agent_runtime::session_control::validate_session_id(session_spec) + .map_err(anyhow::Error::msg)?; + Ok(session_spec.to_string()) +} + +/// Validate an agent override against the agent registry. +/// Returns the valid agent ID, or an error with a warning message. +async fn resolve_agent_override(agent_override: &str) -> std::result::Result { + let registry = bitfun_core::agentic::get_agent_registry(); + let modes = registry.get_modes_info().await; + if modes.iter().any(|m| m.id == agent_override) { + Ok(agent_override.to_string()) + } else { + let available: Vec<&str> = modes.iter().map(|m| m.id.as_str()).collect(); + Err(format!( + "Warning: Agent '{agent_override}' not found. Available: {}. Using default.", + available.join(", ") + )) + } +} + // ======================== Main ======================== #[derive(Debug)] @@ -1149,7 +1239,16 @@ async fn run_cli() -> Result<()> { match cli.command { Some(Commands::Chat { agent, .. }) => { // Interactive mode with startup page, scoped to the current directory. - run_interactive(config, agent, ".".to_string(), use_shared_runtime).await?; + run_interactive( + config, + agent, + ".".to_string(), + use_shared_runtime, + cli.agent.clone(), + cli.model.clone(), + None, + ) + .await?; } Some(Commands::SharedRuntime { @@ -1410,7 +1509,24 @@ async fn run_cli() -> Result<()> { let workspace_str = ".".to_string(); let default_agent = config.behavior.default_agent.clone(); - run_interactive(config, default_agent, workspace_str, use_shared_runtime).await?; + + // Resolve --continue / --session into a session override spec. + let session_override = if cli.continue_last { + Some("last".to_string()) + } else { + cli.session.clone() + }; + + run_interactive( + config, + default_agent, + workspace_str, + use_shared_runtime, + cli.agent.clone(), + cli.model.clone(), + session_override, + ) + .await?; } } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 4fb56eaa9c..eb768d53a1 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -522,6 +522,8 @@ pub(crate) struct ChatMode { restore_session_id: Option, /// If set, send this prompt automatically when the session starts initial_prompt: Option, + /// If set, override the session model after create/restore + model_id: Option, /// Pending MCP operation — set in key handler, executed after one render frame pending_mcp_op: Option, /// Running MCP tasks (non-blocking, polled in main loop) @@ -614,6 +616,7 @@ impl ChatMode { auto_approve_ask_override: None, restore_session_id: None, initial_prompt: None, + model_id: None, pending_mcp_op: None, pending_mcp_tasks: Vec::new(), pending_session_operation: None, @@ -666,6 +669,12 @@ impl ChatMode { self } + /// Set a model ID to override the session model after create/restore + pub(crate) fn with_model(mut self, model_id: String) -> Self { + self.model_id = Some(model_id); + self + } + fn action_state(&self, is_processing: bool, popup_open: bool) -> ActionState { ActionState::chat(is_processing, popup_open) .with_shared_tui(self.agent.is_shared()) diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index bc1ac3847a..1fc785cdd8 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -454,6 +454,22 @@ impl ChatMode { self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(&mut chat_state, &rt_handle); + // Apply model override (--model flag): update the session model. + // The backend validates the ID; an invalid ID logs a warning and + // falls back to the default model. + if let Some(ref model_override) = self.model_id { + let trimmed = model_override.trim(); + let sid = chat_state.core_session_id.clone(); + let mid = trimmed.to_string(); + let agent = self.agent.clone(); + if let Err(e) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { agent.update_session_model(&sid, &mid).await }) + }) { + tracing::warn!("Failed to apply model override '{mid}': {e}"); + eprintln!("Warning: Model '{mid}' not found. Using default model."); + } + } + let mut external_source_rx = None; if self.agent.is_shared() { chat_view.set_status(Some(format!( diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 105d93c790..df87546a21 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -319,6 +319,16 @@ impl StartupPage { &self.agent_type } + /// Set a model ID override (from `--model` flag) for display and session + /// composition. The ID is validated when applied to the session; an invalid + /// ID logs a warning and falls back to the default model. + pub(crate) fn set_model_override(&mut self, model_id: Option) { + if model_id.is_some() { + self.selected_model_id = model_id; + } + self.load_current_model_name(); + } + /// Return the model explicitly selected for the new Session, if any. pub(crate) fn selected_model_id(&self) -> Option<&str> { self.selected_model_id.as_deref() From de98372a9d24dcc21584ae0ebe71a8aeb0a3ebd4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 01:18:15 -0700 Subject: [PATCH 004/206] docs: tighten promotion claims and onboarding --- README.md | 19 ++++++++++++++----- README.zh-CN.md | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9cf128b9e0..394746829c 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ![BitFun](./png/BitFun_title.png) -### An open-source desktop AI agent that turns every task into an app you can open +### A desktop AI agent that turns every task into an app you can open -Writes code, produces documents, drives the desktop. The Mini Apps, the runtime, and the device-sync server are all yours. MIT. +Writes code, produces documents, and drives the desktop — with Mini Apps, a Rust runtime, and a self-hostable device-sync server. [**⬇ Download for macOS · Windows · Linux**](https://github.com/GCWing/BitFun/releases/latest) @@ -15,7 +15,7 @@ Writes code, produces documents, drives the desktop. The Mini Apps, the runtime, [![GitHub release](https://img.shields.io/github/v/release/GCWing/BitFun?style=flat-square&color=blue)](https://github.com/GCWing/BitFun/releases) [![Downloads](https://img.shields.io/github/downloads/GCWing/BitFun/total?style=flat-square&color=brightgreen)](https://github.com/GCWing/BitFun/releases) [![Stars](https://img.shields.io/github/stars/GCWing/BitFun?style=flat-square&color=yellow)](https://github.com/GCWing/BitFun/stargazers) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) +[![Core code: MIT](https://img.shields.io/badge/core_code-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue?style=flat-square)](https://github.com/GCWing/BitFun/releases) [![Trendshift](https://trendshift.io/api/badge/repositories/44672)](https://trendshift.io/repositories/44672) @@ -40,7 +40,7 @@ Writes code, produces documents, drives the desktop. The Mini Apps, the runtime, | **Desktop execution** | Browser, terminal, desktop applications, the filesystem, and remote workspaces | | **Four tiers of customization** | Custom Agents → MCP / Skills / Hooks → Mini Apps → source-level changes | | **Performance** | 98.67% average KV cache hit rate; flashgrep searches Chromium-scale trees ~36x faster | -| **Cross-platform and open** | Windows, macOS, and Linux. MIT. Model-agnostic — you choose what it runs on | +| **Cross-platform and model-agnostic** | Windows, macOS, and Linux. You choose what it runs on | --- @@ -50,6 +50,8 @@ Writes code, produces documents, drives the desktop. The Mini Apps, the runtime, ![Mini Apps gallery](./png/miniapps_gallery.png) +[Browse the public Mini Apps gallery →](https://market.openbitfun.com/miniapp/) + **Self-hosted multi-device control.** Account login, cross-device session and settings sync, and controlling one signed-in device from another all run through a relay *you* deploy. Nothing is brokered by a vendor's cloud — the distinction that decides whether this is allowed inside a company network at all. The relay is zero-knowledge: clients derive keys locally, and the server only ever holds Argon2id hashes and AES-GCM-wrapped material. **A runtime you can reshape.** Four continuous tiers, from a single Markdown file to forking the runtime: custom Agents → MCP / Skills / Codex-compatible Hooks → Mini Apps → source-level changes. You extend BitFun using BitFun. @@ -73,6 +75,13 @@ pnpm run desktop:dev Prerequisites: [Node.js](https://nodejs.org/) 22.12+ (LTS recommended), [pnpm](https://pnpm.io/) 10.15.0 via Corepack, the [Rust toolchain](https://rustup.rs/), and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). More detail in [CONTRIBUTING.md](./CONTRIBUTING.md). +### First run + +1. Launch BitFun, click **Open** on the Welcome tab, and choose a project folder. +2. Open **More options (…) → Settings → Models → Create First Configuration**. +3. Choose a provider, enter its API key, select one or more models, and click **Save**. BitFun makes the first saved model primary and tests the connection automatically. +4. Return to the **Session** tab, type a concrete task, and press Enter or click **Send**. + --- ## What you can hand to BitFun @@ -99,7 +108,7 @@ The data below evaluates BitFun's core Agent capabilities, all measured with **D > [!NOTE] > These are BitFun's initial evaluation results, with each case run once. Benchmarks fluctuate with task sampling, model versions, runtime environment, and single-run variance, so treat these as an initial sanity signal that the Agent is already reasonably capable — not as a fixed ranking claim or a final ceiling. Full benchmark details will follow. -**1. Completion results** — BitFun leads Open Code and Claude Code on both **SWE-Bench-Pro** (complex software engineering) and **SWE-Bench-Verified** (human-verified GitHub issue fixes). +**1. Initial completion snapshot** — The chart below compares the current single-run results on **SWE-Bench-Pro** (complex software engineering) and **SWE-Bench-Verified** (human-verified GitHub issue fixes). ![Agent benchmark scores](./png/agent_benchmark_scores.svg) diff --git a/README.zh-CN.md b/README.zh-CN.md index 035055a2c9..0b7d34899d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,9 +4,9 @@ ![BitFun](./png/BitFun_title.png) -### 开源桌面 AI Agent —— 每个任务,都给你一个能打开的应用 +### 桌面 AI Agent —— 每个任务,都给你一个能打开的应用 -能写代码、能做文档、能操控桌面。小应用、Runtime、多设备互控的服务器,全部归你。MIT。 +能写代码、能做文档、能操控桌面,并提供小应用、Rust Runtime 和可自部署的多设备互控服务器。 [**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) @@ -15,7 +15,7 @@ [![GitHub release](https://img.shields.io/github/v/release/GCWing/BitFun?style=flat-square&color=blue)](https://github.com/GCWing/BitFun/releases) [![Downloads](https://img.shields.io/github/downloads/GCWing/BitFun/total?style=flat-square&color=brightgreen)](https://github.com/GCWing/BitFun/releases) [![Stars](https://img.shields.io/github/stars/GCWing/BitFun?style=flat-square&color=yellow)](https://github.com/GCWing/BitFun/stargazers) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) +[![Core code: MIT](https://img.shields.io/badge/core_code-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue?style=flat-square)](https://github.com/GCWing/BitFun/releases) [![Trendshift](https://trendshift.io/api/badge/repositories/44672)](https://trendshift.io/repositories/44672) @@ -40,7 +40,7 @@ | **桌面执行层** | 浏览器、终端、桌面软件、文件系统、远程工作区 | | **四层可定制** | 自定义 Agent → MCP / Skills / Hooks → Mini App → 源码级改造 | | **性能** | KV Cache 平均命中率 98.67%;flashgrep 在千万行仓库上搜索平均快约 36 倍 | -| **跨平台开源** | Windows、macOS、Linux 三端。MIT。模型自选,不绑定厂商 | +| **跨平台、模型自选** | Windows、macOS、Linux 三端,不绑定模型厂商 | --- @@ -50,6 +50,8 @@ ![小应用 Gallery](./png/miniapps_gallery_CN.png) +[浏览公开 Mini App Gallery →](https://market.openbitfun.com/miniapp/) + **自部署的多设备互联互控。** 账号登录、跨设备会话与配置同步、用一台设备操控另一台已登录设备,全部走**你自己部署**的 relay,不经任何第三方云中转——这往往直接决定了它在企业内网里能不能用。relay 是零知识设计:密钥在客户端本地派生,服务端只保存 Argon2id 哈希和 AES-GCM 封装后的材料。 **可以改到底的 Runtime。** 从一个 Markdown 文件到 fork 整个 Runtime,四层连续:自定义 Agent → MCP / Skills / 兼容 Codex 的 Hooks → Mini App → 源码级改造。你可以用 BitFun 来扩展 BitFun。 @@ -73,6 +75,13 @@ pnpm run desktop:dev 前置依赖:[Node.js](https://nodejs.org/) 22.12+(推荐 LTS)、[pnpm](https://pnpm.io/) 10.15.0(建议通过 Corepack 使用)、[Rust 工具链](https://rustup.rs/)、[Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)。更多说明见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 +### 第一次运行 + +1. 启动 BitFun,在欢迎页点击**打开**,选择一个项目文件夹。 +2. 打开**更多选项(…)→ 设置 → 模型 → 创建第一个配置**。 +3. 选择服务商,填写 API Key,选择一个或多个模型,然后点击**保存**。第一个保存的模型会自动成为主模型,并自动测试连接。 +4. 回到**会话**页,输入一个具体任务,按 Enter 或点击**发送**。 + --- ## 你可以把什么交给 BitFun @@ -99,7 +108,7 @@ pnpm run desktop:dev > [!NOTE] > 当前数据为每个 case 跑 1 次得到的 BitFun 初始评测结果。评测会受到任务抽样、模型版本、运行环境和单次执行偶然性的影响,存在一定波动;这组数据仅用于说明当前 Agent 已具备可用的基础竞争力,并不代表固定排名或最终上限。后续会持续优化并放出完整评测详情。 -**1. 完成效果** —— BitFun 在 **SWE-Bench-Pro**(复杂软件工程)和 **SWE-Bench-Verified**(人工验证的 GitHub issue 修复)上均领先 Open Code 与 Claude Code。 +**1. 初始完成效果快照** —— 下图对比了 **SWE-Bench-Pro**(复杂软件工程)和 **SWE-Bench-Verified**(人工验证的 GitHub issue 修复)当前的单次运行结果。 ![Agent benchmark scores](./png/agent_benchmark_scores.svg) From 3ecc752f46a99aa58a9ef99b32d0ff801bf99b70 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 01:36:32 -0700 Subject: [PATCH 005/206] fix(session): recover corrupt session indexes --- .../core/src/agentic/persistence/manager.rs | 118 ++++++ .../src/session/metadata_store.rs | 350 ++++++++++++++---- 2 files changed, 403 insertions(+), 65 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 5e37b31c68..6ef94eb095 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -4111,6 +4111,7 @@ mod tests { }; use crate::BitFunError; use bitfun_runtime_ports::SessionTurnWindowRequest; + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -6769,6 +6770,123 @@ mod tests { let _ = std::fs::remove_dir_all(&test_root); } + #[tokio::test] + async fn corrupt_remote_index_does_not_block_history_updates_or_new_sessions() { + let workspace = TestWorkspace::new(); + let path_manager = workspace.path_manager(); + let manager = PersistenceManager::new(path_manager.clone()).expect("persistence manager"); + let sessions_dir = crate::service::WorkspaceRuntimeService::new(path_manager) + .context_for_remote_workspace("dev-host", "/home/wsp/project") + .sessions_dir; + let config = SessionConfig { + workspace_path: Some("/home/wsp/project".to_string()), + remote_connection_id: Some("ssh-1".to_string()), + remote_ssh_host: Some("dev-host".to_string()), + ..Default::default() + }; + let historical_id = Uuid::new_v4().to_string(); + let historical = Session::new_with_id( + historical_id.clone(), + "Historical remote session".to_string(), + "agentic".to_string(), + config.clone(), + ); + manager + .create_session_if_absent(&sessions_dir, &historical) + .await + .expect("historical remote session should persist"); + let first_turn = DialogTurnData::new( + "turn-0".to_string(), + 0, + historical_id.clone(), + UserMessageData { + id: "user-0".to_string(), + content: "before restart".to_string(), + timestamp: 1, + metadata: None, + }, + ); + manager + .save_dialog_turn(&sessions_dir, &first_turn) + .await + .expect("historical remote turn should persist"); + let state_path = sessions_dir.join(&historical_id).join("state.json"); + let first_turn_path = sessions_dir + .join(&historical_id) + .join("turns") + .join("turn-0000.json"); + let state_before = std::fs::read(&state_path).expect("historical state should exist"); + let first_turn_before = + std::fs::read(&first_turn_path).expect("historical turn should exist"); + + std::fs::write(sessions_dir.join("index.json"), b"") + .expect("simulate an empty remote index after abnormal restart"); + let restored = manager + .load_session(&sessions_dir, &historical_id) + .await + .expect("history must open even before the derived index is repaired"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0"]); + + let second_turn = DialogTurnData::new( + "turn-1".to_string(), + 1, + historical_id.clone(), + UserMessageData { + id: "user-1".to_string(), + content: "after restart".to_string(), + timestamp: 2, + metadata: None, + }, + ); + manager + .save_dialog_turn(&sessions_dir, &second_turn) + .await + .expect("the historical Session must accept a new turn with a corrupt index"); + + std::fs::write(sessions_dir.join("index.json"), b"{") + .expect("simulate another interrupted index write before Session creation"); + let new_session_id = Uuid::new_v4().to_string(); + let new_session = Session::new_with_id( + new_session_id.clone(), + "New remote session".to_string(), + "agentic".to_string(), + config, + ); + manager + .create_session_if_absent(&sessions_dir, &new_session) + .await + .expect("a corrupt remote index must not block new Session creation"); + + std::fs::write(sessions_dir.join("index.json"), b" ") + .expect("simulate a corrupt index before listing"); + let listed = manager + .list_session_metadata(&sessions_dir) + .await + .expect("remote Session listing should rebuild its derived index"); + let listed_ids = listed + .iter() + .map(|metadata| metadata.session_id.as_str()) + .collect::>(); + assert_eq!( + listed_ids, + HashSet::from([historical_id.as_str(), new_session_id.as_str()]) + ); + + let restored = manager + .load_session(&sessions_dir, &historical_id) + .await + .expect("updated historical Session should remain restorable"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0", "turn-1"]); + assert_eq!( + std::fs::read(state_path).expect("historical state remains readable"), + state_before + ); + assert_eq!( + std::fs::read(first_turn_path).expect("historical turn remains readable"), + first_turn_before + ); + } + #[tokio::test] async fn skill_agent_snapshots_persist_and_truncate_with_context_snapshots() { let workspace = TestWorkspace::new(); diff --git a/src/crates/services/services-core/src/session/metadata_store.rs b/src/crates/services/services-core/src/session/metadata_store.rs index e19e8f1fe5..286a3a661a 100644 --- a/src/crates/services/services-core/src/session/metadata_store.rs +++ b/src/crates/services/services-core/src/session/metadata_store.rs @@ -235,14 +235,56 @@ impl SessionMetadataStore { Ok(count) } - async fn rebuild_index_locked( + async fn rebuild_index_snapshot_locked( &self, - ) -> Result, SessionMetadataStoreError> { + ) -> Result<(StoredSessionIndexFile, Vec), SessionMetadataStoreError> { let metadata_list = self.scan_metadata_dirs().await?; let (index, visible_sessions) = build_session_index_snapshot(metadata_list, current_unix_ms()); self.write_json_atomic(&self.index_path(), &index).await?; - Ok(visible_sessions) + Ok((index, visible_sessions)) + } + + async fn rebuild_index_locked( + &self, + ) -> Result, SessionMetadataStoreError> { + self.rebuild_index_snapshot_locked() + .await + .map(|(_, visible_sessions)| visible_sessions) + } + + /// Load the rebuildable Session index while the caller owns both index locks. + /// + /// Per-session `metadata.json` files are authoritative. Older BitFun versions + /// can leave `index.json` missing, empty, or truncated if the machine stops + /// during the Windows direct-overwrite fallback. Treat only index + /// deserialization failures as recoverable; real filesystem errors must still + /// reach the caller. + async fn read_or_rebuild_index_locked( + &self, + ) -> Result<(StoredSessionIndexFile, bool), SessionMetadataStoreError> { + let index_path = self.index_path(); + match self + .read_json_optional::(&index_path) + .await + { + Ok(Some(index)) => Ok((index, false)), + Ok(None) => self + .rebuild_index_snapshot_locked() + .await + .map(|(index, _)| (index, true)), + Err(error) if error.is_deserialization() => { + warn!( + "Session index is unreadable; rebuilding from per-session metadata: path={}, error={}", + index_path.display(), + error + ); + self.rebuild_index_snapshot_locked() + .await + .map(|(index, _)| (index, true)) + } + Err(error) => Err(error), + } } async fn upsert_index_entry_locked( @@ -250,23 +292,18 @@ impl SessionMetadataStore { metadata: &SessionMetadata, metadata_file_created: bool, ) -> Result<(), SessionMetadataStoreError> { - let index_path = self.index_path(); - let existing_index = self - .read_json_optional::(&index_path) - .await?; - let disk_metadata_file_count = if existing_index.is_some() { - 0 - } else { - self.count_metadata_dirs().await? - }; + let (existing_index, rebuilt) = self.read_or_rebuild_index_locked().await?; + if rebuilt { + return Ok(()); + } let index = upsert_session_index_entry( - existing_index, + Some(existing_index), metadata, metadata_file_created, - disk_metadata_file_count, + 0, current_unix_ms(), ); - self.write_json_atomic(&index_path, &index).await + self.write_json_atomic(&self.index_path(), &index).await } async fn remove_index_entry_locked( @@ -274,19 +311,19 @@ impl SessionMetadataStore { session_id: &str, metadata_file_count_delta: isize, ) -> Result<(), SessionMetadataStoreError> { - let index_path = self.index_path(); - let existing_index = self - .read_json_optional::(&index_path) - .await?; + let (existing_index, rebuilt) = self.read_or_rebuild_index_locked().await?; + if rebuilt { + return Ok(()); + } let Some(index) = remove_session_index_entry( - existing_index, + Some(existing_index), session_id, metadata_file_count_delta, current_unix_ms(), ) else { return Ok(()); }; - self.write_json_atomic(&index_path, &index).await + self.write_json_atomic(&self.index_path(), &index).await } pub async fn list_metadata(&self) -> Result, SessionMetadataStoreError> { @@ -298,37 +335,31 @@ impl SessionMetadataStore { let _guard = lock.lock().await; let _file_guard = self.lock_index_file().await?; let index_path = self.index_path(); - if let Some(index) = self - .read_json_optional::(&index_path) - .await? - { - let has_stale_entry = index - .sessions - .iter() - .any(|metadata| !self.metadata_path(&metadata.session_id).exists()); - if has_stale_entry { - warn!( - "Session index contains stale entries, rebuilding: {}", - index_path.display() - ); - return self.rebuild_index_locked().await; - } - - let disk_count = self.count_metadata_dirs().await?; - if index.metadata_file_count != disk_count { - warn!( - "Session index incomplete (index: {}, disk: {}), rebuilding: {}", - index.metadata_file_count, - disk_count, - index_path.display() - ); - return self.rebuild_index_locked().await; - } + let (index, _) = self.read_or_rebuild_index_locked().await?; + let has_stale_entry = index + .sessions + .iter() + .any(|metadata| !self.metadata_path(&metadata.session_id).exists()); + if has_stale_entry { + warn!( + "Session index contains stale entries, rebuilding: {}", + index_path.display() + ); + return self.rebuild_index_locked().await; + } - return Ok(index.sessions); + let disk_count = self.count_metadata_dirs().await?; + if index.metadata_file_count != disk_count { + warn!( + "Session index incomplete (index: {}, disk: {}), rebuilding: {}", + index.metadata_file_count, + disk_count, + index_path.display() + ); + return self.rebuild_index_locked().await; } - self.rebuild_index_locked().await + Ok(index.sessions) } pub async fn list_metadata_page( @@ -345,23 +376,17 @@ impl SessionMetadataStore { let _guard = lock.lock().await; let _file_guard = self.lock_index_file().await?; let index_path = self.index_path(); - let indexed_sessions = if let Some(index) = self - .read_json_optional::(&index_path) - .await? - { - if index.metadata_file_count < index.sessions.len() { - warn!( - "Session index has invalid metadata count before page read (index: {}, sessions: {}), rebuilding: {}", - index.metadata_file_count, - index.sessions.len(), - index_path.display() - ); - self.rebuild_index_locked().await? - } else { - index.sessions - } - } else { + let (index, _) = self.read_or_rebuild_index_locked().await?; + let indexed_sessions = if index.metadata_file_count < index.sessions.len() { + warn!( + "Session index has invalid metadata count before page read (index: {}, sessions: {}), rebuilding: {}", + index.metadata_file_count, + index.sessions.len(), + index_path.display() + ); self.rebuild_index_locked().await? + } else { + index.sessions }; let page = build_session_metadata_page(indexed_sessions, cursor, limit); @@ -599,6 +624,201 @@ mod tests { assert_eq!(listed[0].session_id, "session-a"); } + #[tokio::test] + async fn metadata_store_recovers_empty_index_while_saving_new_metadata() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + store + .save_metadata(&metadata("historical", 20)) + .await + .expect("save historical metadata"); + let historical_turn = store + .session_dir("historical") + .join("turns") + .join("turn-0000.json"); + fs::create_dir_all(historical_turn.parent().expect("turn parent")) + .await + .expect("create historical turns directory"); + fs::write(&historical_turn, b"historical turn payload") + .await + .expect("write historical turn sentinel"); + + fs::write(store.index_path(), b"") + .await + .expect("simulate an empty index after an interrupted write"); + store + .save_metadata(&metadata("new-session", 10)) + .await + .expect("a corrupt derived index must not block a new session"); + + let listed = store.list_metadata().await.expect("list rebuilt metadata"); + assert_eq!( + listed + .iter() + .map(|value| value.session_id.as_str()) + .collect::>(), + vec!["historical", "new-session"] + ); + assert_eq!( + fs::read(&historical_turn) + .await + .expect("historical turn must remain readable"), + b"historical turn payload" + ); + let rebuilt = store + .read_json_optional::(&store.index_path()) + .await + .expect("read rebuilt index") + .expect("rebuilt index exists"); + assert_eq!(rebuilt.metadata_file_count, 2); + assert_eq!(rebuilt.sessions.len(), 2); + } + + #[tokio::test] + async fn metadata_store_page_recovers_truncated_index() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + store + .save_metadata(&metadata("older", 10)) + .await + .expect("save older metadata"); + store + .save_metadata(&metadata("newer", 20)) + .await + .expect("save newer metadata"); + fs::write(store.index_path(), br#"{"schema_version":2,"updated_at":"#) + .await + .expect("simulate a truncated index"); + + let page = store + .list_metadata_page(None, 10) + .await + .expect("paged listing must rebuild a truncated index"); + + assert_eq!( + page.sessions + .iter() + .map(|value| value.session_id.as_str()) + .collect::>(), + vec!["newer", "older"] + ); + } + + #[tokio::test] + async fn metadata_store_delete_recovers_corrupt_index_and_preserves_other_sessions() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + store + .save_metadata(&metadata("historical", 20)) + .await + .expect("save historical metadata"); + store + .save_metadata(&metadata("partial-create", 10)) + .await + .expect("save partial create metadata"); + fs::write(store.index_path(), b"") + .await + .expect("simulate an empty index"); + + store + .delete_session_dir_and_index("partial-create") + .await + .expect("cleanup must rebuild the corrupt index"); + + assert!(!store.session_dir("partial-create").exists()); + assert!(store.session_dir("historical").exists()); + let listed = store.list_metadata().await.expect("list surviving session"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].session_id, "historical"); + } + + #[tokio::test] + async fn metadata_store_rebuilds_missing_index_before_save_without_hiding_history() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + store + .save_metadata(&metadata("historical", 20)) + .await + .expect("save historical metadata"); + fs::remove_file(store.index_path()) + .await + .expect("simulate the replace gap left by an older version"); + + store + .save_metadata(&metadata("new-session", 10)) + .await + .expect("save with a missing derived index"); + + let listed = store.list_metadata().await.expect("list rebuilt metadata"); + assert_eq!( + listed + .iter() + .map(|value| value.session_id.as_str()) + .collect::>(), + vec!["historical", "new-session"] + ); + } + + #[tokio::test] + async fn metadata_store_rebuilds_legacy_index_without_metadata_file_count() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + let historical = metadata("historical", 20); + store + .save_metadata(&historical) + .await + .expect("save historical metadata"); + let legacy_index = serde_json::json!({ + "schema_version": 2, + "updated_at": 1, + "sessions": [historical] + }); + fs::write( + store.index_path(), + serde_json::to_vec(&legacy_index).expect("serialize legacy index"), + ) + .await + .expect("write legacy index"); + + let listed = store + .list_metadata() + .await + .expect("legacy index remains upgrade-compatible"); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].session_id, "historical"); + let rebuilt = store + .read_json_optional::(&store.index_path()) + .await + .expect("read upgraded index") + .expect("upgraded index exists"); + assert_eq!(rebuilt.metadata_file_count, 1); + } + + #[tokio::test] + async fn metadata_store_does_not_treat_index_io_errors_as_corruption() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + store + .save_metadata(&metadata("session-a", 10)) + .await + .expect("save metadata"); + fs::remove_file(store.index_path()) + .await + .expect("remove index file"); + fs::create_dir(store.index_path()) + .await + .expect("replace index with an unreadable directory"); + + let error = store + .list_metadata() + .await + .expect_err("filesystem errors must not be swallowed as corrupt JSON"); + + assert!(!error.is_deserialization()); + assert!(store.index_path().is_dir()); + } + #[tokio::test] async fn metadata_store_rebuilds_stale_index_entries() { let dir = tempdir().expect("tempdir"); From 4db7f7ea2a2b8f2b554c6bfdd64047ab2af43c50 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 02:14:01 -0700 Subject: [PATCH 006/206] docs: expose quick start and security links --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 394746829c..bd08439915 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Writes code, produces documents, and drives the desktop — with Mini Apps, a Ru [**⬇ Download for macOS · Windows · Linux**](https://github.com/GCWing/BitFun/releases/latest) -[Website](https://openbitfun.com/) · [Docs](./docs) · [Discussions](https://github.com/GCWing/BitFun/discussions) · [Contributing](./CONTRIBUTING.md) +[Website](https://openbitfun.com/) · [Quick start](#first-run) · [Security](./SECURITY.md) · [Discussions](https://github.com/GCWing/BitFun/discussions) · [Contributing](./CONTRIBUTING.md) [![GitHub release](https://img.shields.io/github/v/release/GCWing/BitFun?style=flat-square&color=blue)](https://github.com/GCWing/BitFun/releases) [![Downloads](https://img.shields.io/github/downloads/GCWing/BitFun/total?style=flat-square&color=brightgreen)](https://github.com/GCWing/BitFun/releases) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0b7d34899d..c59618d5b7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ [**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) -[官网](https://openbitfun.com/) · [文档](./docs) · [讨论区](https://github.com/GCWing/BitFun/discussions) · [参与贡献](./CONTRIBUTING_CN.md) +[官网](https://openbitfun.com/) · [快速开始](#第一次运行) · [安全策略](./SECURITY_CN.md) · [讨论区](https://github.com/GCWing/BitFun/discussions) · [参与贡献](./CONTRIBUTING_CN.md) [![GitHub release](https://img.shields.io/github/v/release/GCWing/BitFun?style=flat-square&color=blue)](https://github.com/GCWing/BitFun/releases) [![Downloads](https://img.shields.io/github/downloads/GCWing/BitFun/total?style=flat-square&color=brightgreen)](https://github.com/GCWing/BitFun/releases) From 8996c20a14ffec54cc6bf706068d3484e61e1b1c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 02:18:20 -0700 Subject: [PATCH 007/206] docs: add safe GitHub social preview candidate --- png/github_social_preview.png | Bin 0 -> 564432 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 png/github_social_preview.png diff --git a/png/github_social_preview.png b/png/github_social_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..52fc14730224893dd3092e541be3887f15e6a9bd GIT binary patch literal 564432 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

O7w{OUCawSyft#0B=LMe+U4w%*6_*dnohl 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


U9}Jl7N`r)uO(K1cLcYqr8if@sUQw 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

m-d(WdOUzsJK(XrQ4T{|Y!YD} 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�F%`F2PiA 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 Date: Thu, 6 Aug 2026 02:33:56 -0700 Subject: [PATCH 008/206] docs: explain how to verify release downloads --- README.md | 2 +- README.zh-CN.md | 2 +- docs/verify-downloads.md | 70 ++++++++++++++++++++++++++++++++++ docs/verify-downloads.zh-CN.md | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 docs/verify-downloads.md create mode 100644 docs/verify-downloads.zh-CN.md diff --git a/README.md b/README.md index 394746829c..6d2a9eaa6f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Writes code, produces documents, and drives the desktop — with Mini Apps, a Rust runtime, and a self-hostable device-sync server. -[**⬇ Download for macOS · Windows · Linux**](https://github.com/GCWing/BitFun/releases/latest) +[**⬇ Download for macOS · Windows · Linux**](https://github.com/GCWing/BitFun/releases/latest) · [Verify downloads](./docs/verify-downloads.md) [Website](https://openbitfun.com/) · [Docs](./docs) · [Discussions](https://github.com/GCWing/BitFun/discussions) · [Contributing](./CONTRIBUTING.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0b7d34899d..0d03327a5f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ 能写代码、能做文档、能操控桌面,并提供小应用、Rust Runtime 和可自部署的多设备互控服务器。 -[**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) +[**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) · [校验下载](./docs/verify-downloads.zh-CN.md) [官网](https://openbitfun.com/) · [文档](./docs) · [讨论区](https://github.com/GCWing/BitFun/discussions) · [参与贡献](./CONTRIBUTING_CN.md) diff --git a/docs/verify-downloads.md b/docs/verify-downloads.md new file mode 100644 index 0000000000..05584ed5ac --- /dev/null +++ b/docs/verify-downloads.md @@ -0,0 +1,70 @@ +[中文](./verify-downloads.zh-CN.md) | **English** + +# Verify BitFun downloads + +Signed BitFun releases provide a detached `.sig` file for each covered +desktop installer or CLI archive. Release `v0.2.15`, for example, provides +signatures for its desktop and CLI downloads. + +BitFun uses this pinned minisign public key: + +- Key ID: `50F47CBE6CC0A376` +- Public key: `RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn` + +The same key is published as `minisign.pub` with signed releases and is built +into official BitFun update paths. The commands below pin the key directly so +the signature and key are not both trusted only because they came from the same +download location. + +## macOS or Linux + +Install [minisign](https://github.com/jedisct1/minisign/releases), then run the +following in a new empty directory. Replace both values with the exact tag and +asset name shown on the release page when verifying another download. + +```bash +VERSION=v0.2.15 +ASSET=bitfun-cli-0.2.15-aarch64-unknown-linux-gnu.tar.gz +BASE="https://github.com/GCWing/BitFun/releases/download/$VERSION" +PUBLIC_KEY=RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn + +curl --fail --location --remote-name "$BASE/$ASSET" +curl --fail --location --remote-name "$BASE/$ASSET.sig" +base64 --decode <"$ASSET.sig" >"$ASSET.minisig" +minisign -Vm "$ASSET" -P "$PUBLIC_KEY" -x "$ASSET.minisig" +``` + +A valid download prints `Signature and comment signature verified` and exits +with status 0. Do not run or install the asset if verification fails. + +## Windows PowerShell + +Install minisign, open a new empty directory, and use the exact release tag and +asset name you downloaded: + +```powershell +$Version = "v0.2.15" +$Asset = "BitFun_0.2.15_windows-x86_64-setup.exe" +$Base = "https://github.com/GCWing/BitFun/releases/download/$Version" +$PublicKey = "RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn" + +Invoke-WebRequest "$Base/$Asset" -OutFile $Asset +Invoke-WebRequest "$Base/${Asset}.sig" -OutFile "${Asset}.sig" +$EncodedSignature = (Get-Content "${Asset}.sig" -Raw).Trim() +[IO.File]::WriteAllBytes("${Asset}.minisig", [Convert]::FromBase64String($EncodedSignature)) +minisign -Vm $Asset -P $PublicKey -x "${Asset}.minisig" +if ($LASTEXITCODE -ne 0) { throw "BitFun download signature verification failed" } +``` + +## What the `.sig` file means + +BitFun release `.sig` files are base64-wrapped **minisign signatures**. Decode +one layer before giving the result to the minisign CLI, as shown above. A valid +signature proves that the file's exact bytes match a signature made by the +pinned BitFun release key; changing even one byte makes verification fail. + +This is not platform code signing. In particular, a BitFun `.sig` is not an +Apple Developer ID signature or notarization ticket, and it is not Windows +Authenticode. Gatekeeper and SmartScreen can therefore show their own warnings +independently of a successful minisign check. Signature verification also does +not replace your normal review of the software and its dependencies. diff --git a/docs/verify-downloads.zh-CN.md b/docs/verify-downloads.zh-CN.md new file mode 100644 index 0000000000..d6b46fc471 --- /dev/null +++ b/docs/verify-downloads.zh-CN.md @@ -0,0 +1,67 @@ +**中文** | [English](./verify-downloads.md) + +# 校验 BitFun 下载文件 + +带签名的 BitFun Release 会为覆盖到的桌面安装包或 CLI 归档提供独立的 +`<文件名>.sig`。例如,`v0.2.15` 已为桌面端和 CLI 下载文件提供签名。 + +BitFun 固定使用以下 minisign 公钥: + +- Key ID:`50F47CBE6CC0A376` +- 公钥:`RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn` + +带签名的 Release 还会发布包含同一把公钥的 `minisign.pub`,BitFun 官方更新 +路径也内置了这把公钥。下面的命令直接固定公钥,避免仅仅因为签名和公钥来自 +同一个下载位置就同时信任两者。 + +## macOS 或 Linux + +先安装 [minisign](https://github.com/jedisct1/minisign/releases),然后在一个新建 +的空目录中运行以下命令。校验其他版本时,请将两个变量同时替换为 Release 页面 +显示的准确 tag 和文件名。 + +```bash +VERSION=v0.2.15 +ASSET=bitfun-cli-0.2.15-aarch64-unknown-linux-gnu.tar.gz +BASE="https://github.com/GCWing/BitFun/releases/download/$VERSION" +PUBLIC_KEY=RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn + +curl --fail --location --remote-name "$BASE/$ASSET" +curl --fail --location --remote-name "$BASE/$ASSET.sig" +base64 --decode <"$ASSET.sig" >"$ASSET.minisig" +minisign -Vm "$ASSET" -P "$PUBLIC_KEY" -x "$ASSET.minisig" +``` + +校验成功时会输出 `Signature and comment signature verified`,并以状态码 0 退出。 +如果校验失败,请不要运行或安装该文件。 + +## Windows PowerShell + +安装 minisign 后,打开一个新建的空目录,并使用你所下载文件对应的准确 Release +tag 和文件名: + +```powershell +$Version = "v0.2.15" +$Asset = "BitFun_0.2.15_windows-x86_64-setup.exe" +$Base = "https://github.com/GCWing/BitFun/releases/download/$Version" +$PublicKey = "RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn" + +Invoke-WebRequest "$Base/$Asset" -OutFile $Asset +Invoke-WebRequest "$Base/${Asset}.sig" -OutFile "${Asset}.sig" +$EncodedSignature = (Get-Content "${Asset}.sig" -Raw).Trim() +[IO.File]::WriteAllBytes("${Asset}.minisig", [Convert]::FromBase64String($EncodedSignature)) +minisign -Vm $Asset -P $PublicKey -x "${Asset}.minisig" +if ($LASTEXITCODE -ne 0) { throw "BitFun 下载文件签名校验失败" } +``` + +## `.sig` 文件代表什么 + +BitFun Release 的 `.sig` 是经过一层 base64 包装的 **minisign 签名**。交给 +minisign 命令行工具之前,需要像上面的命令一样先解码一层。校验成功表示文件的 +每个字节都与 BitFun 固定发布公钥对应的签名一致;哪怕只修改一个字节,校验也会 +失败。 + +这不是操作系统级代码签名。BitFun 的 `.sig` 既不是 Apple Developer ID 签名或 +公证票据,也不是 Windows Authenticode。因此,即使 minisign 校验成功,Gatekeeper +或 SmartScreen 仍可能独立显示提示。签名校验也不能替代你对软件及其依赖的正常 +审查。 From 04010fd0427cc02a14b58cfdf0b1cc26bbb83912 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 02:54:34 -0700 Subject: [PATCH 009/206] docs: replace unverifiable vibe-coding percentage --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c455aaea0..16a98a313e 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Please submit PRs directly to the `main` branch. For more details, see [CONTRIBU ## Disclaimer 1. This project is spare-time exploration and research into next-generation human-machine collaboration, not a commercial profit-making project. -2. This project is 97%+ built through Vibe Coding. Code feedback is welcome, and AI-assisted refactoring and optimization are encouraged. +2. AI-assisted development is part of this project's workflow. Contributions are reviewed as code, and AI-assisted PRs should disclose their testing level; see [CONTRIBUTING.md](./CONTRIBUTING.md). 3. This project depends on and references many open-source projects. Thanks to all open-source authors. **If your rights are affected, please contact us for remediation.** --- diff --git a/README.zh-CN.md b/README.zh-CN.md index e340423743..27386405f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -169,5 +169,5 @@ BitFun 的扩展路径从轻到重连续展开: ## 声明 1. 本项目为业余时间探索、研究构建下一代人机协同交互,非商用盈利项目。 -2. 本项目 97%+ 由 Vibe Coding 完成,代码问题欢迎指正,也欢迎通过 AI 进行重构优化。 +2. AI 辅助开发是本项目工作流的一部分。贡献以代码和验证结果为准;AI 辅助 PR 请说明测试程度,详见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 3. 本项目依赖和参考了众多开源软件。感谢所有开源作者。如侵犯您的相关权益,请联系我们整改。 From 97a4afaa50991bedeb3174f5561ec1dfc7cc0398 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 16:48:13 +0800 Subject: [PATCH 010/206] fix(review): restore CodeReview/DeepReview session creation and turns Review child sessions are created with agentType=CodeReview (standard) or DeepReview (strict), but resolve_primary_agent_for_turn only accepted Mode-category agents, so session creation failed with "Unknown session mode: CodeReview" and persisted review sessions could be silently rewritten to agentic on restore. Allow the builtin CodeReview/DeepReview agents to resolve as local session primaries while keeping all other subagents restricted, and add diagnostics at the rejection point. Regression introduced by ca94825ad. --- .../src/agentic/agents/registry/external.rs | 41 +++++++++++++++++-- .../core/src/agentic/agents/registry/tests.rs | 41 +++++++++++++++++++ .../src/agentic/coordination/coordinator.rs | 25 +++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index df1badffd4..a06835e19c 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -8,6 +8,7 @@ use bitfun_core_types::{ }; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use log::{debug, warn}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; @@ -454,9 +455,32 @@ impl AgentRegistry { if expected_owner == Some(SessionAgentRouteOwner::External) { return None; } - self.find_agent_entry(logical_id, workspace_root) - .filter(|entry| entry.category == AgentCategory::Mode) - .map(|entry| local_primary_binding(entry.agent.id())) + match self.find_agent_entry(logical_id, workspace_root) { + Some(entry) + if entry.category == AgentCategory::Mode + || (entry.source == AgentSource::Builtin + && is_builtin_session_primary_agent(entry.agent.id())) => + { + Some(local_primary_binding(entry.agent.id())) + } + Some(entry) => { + warn!( + "Session primary agent resolution rejected a registered non-mode agent: logical_id={}, category={:?}, source={:?}, expected_owner={:?}", + logical_id, + entry.category, + entry.source, + expected_owner + ); + None + } + None => { + debug!( + "Session primary agent resolution found no registered agent: logical_id={}, expected_owner={:?}", + logical_id, expected_owner + ); + None + } + } } /// Resolve only the currently approved external route for an exact @@ -576,6 +600,17 @@ fn local_binding(logical_id: &str, runtime_agent_key: &str) -> ExternalSubagentI } } +/// Builtin agents that are allowed to act as the main agent of a session even +/// though they are not registered as `Mode` (review child sessions). +/// +/// Review child sessions are created by the product surfaces with +/// `agentType=CodeReview` (standard) or `agentType=DeepReview` (strict) and +/// must resolve through the primary-agent path for create, turn, restore, and +/// compaction. Other subagents (e.g. `ReviewWorker`) stay restricted. +fn is_builtin_session_primary_agent(id: &str) -> bool { + matches!(id, "CodeReview" | "DeepReview") +} + fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { ExternalPrimaryAgentTurnBinding { runtime_agent_key: runtime_agent_key.to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index ee6934f8f8..3753dd9a55 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1589,3 +1589,44 @@ fn external_primary_route_follows_the_session_execution_worktree() { assert_eq!(binding.runtime_agent_key, "external::worktree"); } + +#[test] +fn builtin_review_agents_resolve_as_local_session_primaries() { + let registry = AgentRegistry::new(); + + for agent_type in ["CodeReview", "DeepReview"] { + let binding = registry + .resolve_primary_agent_for_turn(agent_type, None, false, None) + .unwrap_or_else(|| { + panic!("{agent_type} must resolve as a session primary agent for review children") + }); + assert_eq!(binding.runtime_agent_key, agent_type); + assert_eq!( + binding.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); + } +} + +#[test] +fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { + let registry = AgentRegistry::new(); + + // Registered subagents that are not session-capable stay restricted. + assert!(registry + .resolve_primary_agent_for_turn("ReviewWorker", None, false, None) + .is_none()); + // Unknown ids remain unknown. + assert!(registry + .resolve_primary_agent_for_turn("does-not-exist", None, false, None) + .is_none()); + // The external-owner guard still fails closed for review agents. + assert!(registry + .resolve_primary_agent_for_turn( + "CodeReview", + None, + false, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + ) + .is_none()); +} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 4c55aec7b3..e95fefc0bb 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -14535,6 +14535,31 @@ mod tests { assert!(session_manager.get_session("ownership-conflict").is_none()); } + #[tokio::test] + async fn review_agent_child_sessions_create_successfully() { + let (coordinator, _session_manager) = test_coordinator(); + + for agent_type in ["CodeReview", "DeepReview"] { + let workspace = tempfile::tempdir().expect("review workspace"); + let session = coordinator + .create_session_with_workspace( + None, + format!("Review child: {agent_type}"), + agent_type.to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + workspace.path().to_string_lossy().into_owned(), + ) + .await + .unwrap_or_else(|error| { + panic!("{agent_type} review child session must create: {error}") + }); + assert_eq!(session.agent_type, agent_type); + } + } + #[tokio::test] async fn assistant_bootstrap_checks_runtime_ownership_before_files_or_attach() { let ownership_root = tempfile::tempdir().expect("ownership root"); From a28bc4aadf86c459658c8606bc9b035bcb9daa25 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 17:11:20 +0800 Subject: [PATCH 011/206] refactor(review): add CODE_REVIEW_AGENT_TYPE constant for review agent ids Introduce CODE_REVIEW_AGENT_TYPE in agent-runtime deep_review constants and use it in registry review-entry detection and session-primary resolution, replacing the scattered "CodeReview" magic string. This keeps review agent ids consistent with the existing DEEP_REVIEW_AGENT_TYPE / REVIEW_JUDGE_AGENT_TYPE constants. This cleanup also served as the verification content for the /review session fix in this PR: it was written into the workspace and reviewed end-to-end with the /review command after installing the fixed build, confirming review sessions now create and run correctly. --- .../assembly/core/src/agentic/agents/registry/external.rs | 3 ++- .../assembly/core/src/agentic/agents/registry/types.rs | 6 ++++-- src/crates/assembly/core/src/agentic/deep_review_policy.rs | 6 +++--- .../execution/agent-runtime/src/deep_review/constants.rs | 1 + src/crates/execution/agent-runtime/src/deep_review/mod.rs | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index a06835e19c..d1e9ff40f0 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -1,6 +1,7 @@ use super::types::{AgentCategory, AgentEntry, AgentInfo, AgentSource, SubAgentSource}; use super::AgentRegistry; use crate::agentic::agents::{Agent, SubagentVisibilityPolicy}; +use crate::agentic::deep_review_policy::{CODE_REVIEW_AGENT_TYPE, DEEP_REVIEW_AGENT_TYPE}; use crate::agentic::workspace::canonical_local_workspace_path; use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; use bitfun_core_types::{ @@ -608,7 +609,7 @@ fn local_binding(logical_id: &str, runtime_agent_key: &str) -> ExternalSubagentI /// must resolve through the primary-agent path for create, turn, restore, and /// compaction. Other subagents (e.g. `ReviewWorker`) stay restricted. fn is_builtin_session_primary_agent(id: &str) -> bool { - matches!(id, "CodeReview" | "DeepReview") + matches!(id, CODE_REVIEW_AGENT_TYPE | DEEP_REVIEW_AGENT_TYPE) } fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs index c8b8ac6371..0bcc9bbc2d 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -6,7 +6,9 @@ use crate::agentic::agents::{ mode_config_profile_label, mode_config_profile_member_mode_ids, resolve_mode_config_profile_id, Agent, AgentToolPolicyOverrides, }; -use crate::agentic::deep_review_policy::{is_review_worker_agent_type, REVIEW_JUDGE_AGENT_TYPE}; +use crate::agentic::deep_review_policy::{ + is_review_worker_agent_type, CODE_REVIEW_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, +}; pub(super) use bitfun_agent_runtime::agents::SubagentOverrideState; pub use bitfun_agent_runtime::agents::{ BuiltinAgentCategory as AgentCategory, SubAgentSource, SubagentListScope, SubagentQueryContext, @@ -231,7 +233,7 @@ pub(crate) fn is_review_agent_entry(entry: &AgentEntry) -> bool { } is_review_worker_agent_type(agent.id()) - || matches!(agent.id(), REVIEW_JUDGE_AGENT_TYPE | "CodeReview") + || matches!(agent.id(), REVIEW_JUDGE_AGENT_TYPE | CODE_REVIEW_AGENT_TYPE) } pub(crate) fn custom_agent_path(agent: &dyn Agent) -> Option { diff --git a/src/crates/assembly/core/src/agentic/deep_review_policy.rs b/src/crates/assembly/core/src/agentic/deep_review_policy.rs index 0354f32da6..c0eb4e8e9a 100644 --- a/src/crates/assembly/core/src/agentic/deep_review_policy.rs +++ b/src/crates/assembly/core/src/agentic/deep_review_policy.rs @@ -41,9 +41,9 @@ pub use bitfun_agent_runtime::deep_review::{ DeepReviewSharedContextMeasurementSnapshot, DeepReviewStrategyLevel, DeepReviewSubagentRole, FocusedReviewAssignment, FocusedReviewBudgetClaim, ReviewStrategyManifestProfile, ReviewTeamDefinition, ReviewTeamExecutionPolicyDefinition, ReviewTeamRoleDefinition, - CONDITIONAL_REVIEWER_AGENT_TYPES, CORE_REVIEWER_AGENT_TYPES, DEEP_REVIEW_AGENT_TYPE, - LEGACY_REVIEW_WORKER_AGENT_TYPES, REVIEW_FIXER_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, - REVIEW_WORKER_AGENT_TYPE, + CODE_REVIEW_AGENT_TYPE, CONDITIONAL_REVIEWER_AGENT_TYPES, CORE_REVIEWER_AGENT_TYPES, + DEEP_REVIEW_AGENT_TYPE, LEGACY_REVIEW_WORKER_AGENT_TYPES, REVIEW_FIXER_AGENT_TYPE, + REVIEW_JUDGE_AGENT_TYPE, REVIEW_WORKER_AGENT_TYPE, }; const DEFAULT_REVIEW_TEAM_CONFIG_PATH: &str = "ai.review_teams.default"; diff --git a/src/crates/execution/agent-runtime/src/deep_review/constants.rs b/src/crates/execution/agent-runtime/src/deep_review/constants.rs index 1b28b92543..05f833de99 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/constants.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/constants.rs @@ -1,6 +1,7 @@ //! Deep Review agent type and role constants. pub const DEEP_REVIEW_AGENT_TYPE: &str = "DeepReview"; +pub const CODE_REVIEW_AGENT_TYPE: &str = "CodeReview"; pub const REVIEW_JUDGE_AGENT_TYPE: &str = "ReviewJudge"; pub const REVIEW_FIXER_AGENT_TYPE: &str = "ReviewFixer"; pub const REVIEW_WORKER_AGENT_TYPE: &str = "ReviewWorker"; diff --git a/src/crates/execution/agent-runtime/src/deep_review/mod.rs b/src/crates/execution/agent-runtime/src/deep_review/mod.rs index 8ea6099d5f..9f21d090aa 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/mod.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/mod.rs @@ -28,7 +28,7 @@ pub use budget::{ }; pub use concurrency_policy::{DeepReviewConcurrencyPolicy, DeepReviewEffectiveConcurrencySnapshot}; pub use constants::{ - canonical_review_worker_agent_type, is_review_worker_agent_type, + canonical_review_worker_agent_type, is_review_worker_agent_type, CODE_REVIEW_AGENT_TYPE, CONDITIONAL_REVIEWER_AGENT_TYPES, CORE_REVIEWER_AGENT_TYPES, DEEP_REVIEW_AGENT_TYPE, LEGACY_REVIEW_WORKER_AGENT_TYPES, REVIEW_FIXER_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, REVIEW_WORKER_AGENT_TYPE, From 81525d8702316436eebcb326532c4259771dcde5 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 17:36:32 +0800 Subject: [PATCH 012/206] fix(review): honor explicit Local routes when resolving review session primaries resolve_primary_agent_for_turn still filtered the ExternalSubagentRoute::Local branch to AgentCategory::Mode, so workspaces whose route table pins CodeReview/DeepReview to the local implementation (same-name conflict resolved to the local candidate) could still fail to create, restore, or start review sessions. Extract a shared is_local_session_primary_entry predicate used by both the explicit Local-route branch and the no-route fallback, and add a diagnostic warn when a registered entry is rejected under a Local route. --- .../src/agentic/agents/registry/external.rs | 39 ++++++++++++++----- .../core/src/agentic/agents/registry/tests.rs | 33 ++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index d1e9ff40f0..96326a327b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -438,10 +438,23 @@ impl AgentRegistry { .cloned() { let binding = match route { - ExternalSubagentRoute::Local => self - .find_agent_entry(logical_id, Some(workspace_root)) - .filter(|entry| entry.category == AgentCategory::Mode) - .map(|entry| local_primary_binding(entry.agent.id())), + ExternalSubagentRoute::Local => { + match self.find_agent_entry(logical_id, Some(workspace_root)) { + Some(entry) if is_local_session_primary_entry(&entry) => { + Some(local_primary_binding(entry.agent.id())) + } + Some(entry) => { + warn!( + "Session primary agent resolution rejected a registered non-mode agent under a Local route: logical_id={}, category={:?}, source={:?}", + logical_id, + entry.category, + entry.source + ); + None + } + None => None, + } + } ExternalSubagentRoute::External(runtime_key) => { self.external_subagents.acquire_primary(&runtime_key) } @@ -457,11 +470,7 @@ impl AgentRegistry { return None; } match self.find_agent_entry(logical_id, workspace_root) { - Some(entry) - if entry.category == AgentCategory::Mode - || (entry.source == AgentSource::Builtin - && is_builtin_session_primary_agent(entry.agent.id())) => - { + Some(entry) if is_local_session_primary_entry(&entry) => { Some(local_primary_binding(entry.agent.id())) } Some(entry) => { @@ -612,6 +621,18 @@ fn is_builtin_session_primary_agent(id: &str) -> bool { matches!(id, CODE_REVIEW_AGENT_TYPE | DEEP_REVIEW_AGENT_TYPE) } +/// Whether a locally-resolved agent entry may act as a session primary agent. +/// +/// Used by both the explicit `ExternalSubagentRoute::Local` branch and the +/// no-route fallback so review child sessions (CodeReview/DeepReview) resolve +/// identically regardless of whether a workspace route table pins them to the +/// local implementation. +fn is_local_session_primary_entry(entry: &AgentEntry) -> bool { + entry.category == AgentCategory::Mode + || (entry.source == AgentSource::Builtin + && is_builtin_session_primary_agent(entry.agent.id())) +} + fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { ExternalPrimaryAgentTurnBinding { runtime_agent_key: runtime_agent_key.to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 3753dd9a55..4ecdf08fea 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1630,3 +1630,36 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { ) .is_none()); } + +#[test] +fn local_route_resolves_review_agents_as_session_primaries() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/review-local-route"); + registry.install_external_subagent_routes( + &workspace, + Vec::new(), + [ + ("CodeReview".to_string(), ExternalSubagentRoute::Local), + ("DeepReview".to_string(), ExternalSubagentRoute::Local), + ("ReviewWorker".to_string(), ExternalSubagentRoute::Local), + ] + .into_iter() + .collect(), + ); + + for agent_type in ["CodeReview", "DeepReview"] { + let binding = registry + .resolve_primary_agent_for_turn(agent_type, Some(&workspace), true, None) + .unwrap_or_else(|| panic!("{agent_type} must resolve through an explicit Local route")); + assert_eq!(binding.runtime_agent_key, agent_type); + assert_eq!( + binding.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); + } + + // Non-session-primary subagents stay restricted even under a Local route. + assert!(registry + .resolve_primary_agent_for_turn("ReviewWorker", Some(&workspace), true, None) + .is_none()); +} From 7bcb29e720ae296f8658f49034343c367d4d7cb9 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 6 Aug 2026 17:26:23 +0800 Subject: [PATCH 013/206] fix(installer): align AI config and add CI compilation check - Remove deprecated reasoning fields from the installer AIConfig mapping. - Add a Windows CI compilation check for the standalone installer crate. - Extend GitHub workflow contract tests to enforce the installer check. --- .github/workflows/ci.yml | 6 ++++++ BitFun-Installer/src-tauri/src/installer/ai_config.rs | 5 +---- scripts/check-github-config.test.mjs | 8 ++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028bbca96f..7f5f466aa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,12 @@ jobs: - name: Check compilation run: cargo check --locked --workspace + # The installer is intentionally excluded from the root Cargo workspace, + # so the workspace check above cannot catch drift in its shared Rust APIs. + - name: Check installer compilation + if: runner.os == 'Windows' + run: cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml + - name: Run core and desktop library tests run: cargo test --locked -p bitfun-core -p bitfun-desktop --lib diff --git a/BitFun-Installer/src-tauri/src/installer/ai_config.rs b/BitFun-Installer/src-tauri/src/installer/ai_config.rs index 675ff83f9f..70e59feec5 100644 --- a/BitFun-Installer/src-tauri/src/installer/ai_config.rs +++ b/BitFun-Installer/src-tauri/src/installer/ai_config.rs @@ -1,7 +1,7 @@ //! Map installer `ModelConfig` to shared AI adapter config. use crate::installer::types::ModelConfig; -use bitfun_ai_adapters::types::{resolve_request_url, AIConfig, ReasoningMode}; +use bitfun_ai_adapters::types::{resolve_request_url, AIConfig}; use log::warn; /// Build `AIConfig` for the shared AI client. @@ -47,13 +47,10 @@ pub(super) fn ai_config_from_installer_model(m: &ModelConfig) -> Result commandByStep.get('Run subscription authentication tests'), 'cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth', ); + const installerCheck = rustJob.steps.find( + (step) => step.name === 'Check installer compilation', + ); + assert.equal(installerCheck?.if, "runner.os == 'Windows'"); + assert.equal( + installerCheck?.run, + 'cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml', + ); assert.equal( commandByStep.get('Run file watch contract tests'), 'cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts', From 87c436b93cf08c47b97446dd1ccc28a3201acb4c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 04:19:47 -0700 Subject: [PATCH 014/206] feat(agent): read documents with anydoc --- Cargo.lock | 373 ++++++++++- Cargo.toml | 3 + THIRD_PARTY_NOTICES.md | 37 +- src/crates/assembly/core/Cargo.toml | 1 + .../tools/implementations/file_read_tool.rs | 591 ++++++++++++++++-- src/crates/contracts/runtime-ports/src/lib.rs | 13 + src/crates/execution/tool-execution/AGENTS.md | 1 + .../execution/tool-execution/Cargo.toml | 3 + .../tool-execution/src/fs/document.rs | 286 +++++++++ .../execution/tool-execution/src/fs/mod.rs | 2 + .../tool-execution/src/fs/read_file.rs | 148 ++++- .../services/services-core/src/workspace.rs | 36 +- .../src/remote_ssh/workspace_services.rs | 128 +++- 13 files changed, 1516 insertions(+), 106 deletions(-) create mode 100644 src/crates/execution/tool-execution/src/fs/document.rs diff --git a/Cargo.lock b/Cargo.lock index 320e2eeaeb..873fa7956f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -223,6 +223,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anydoc" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d88a76ba2a26a65e133879dee68acd593656e68e306488ebb162b69f37902b" +dependencies = [ + "calamine", + "cfb 0.14.0", + "csv", + "encoding_rs", + "flate2", + "log", + "pdf-inspector", + "quick-xml 0.41.0", + "zip 8.6.0", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -467,6 +484,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1175,7 +1202,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "xz2", - "zip", + "zip 4.6.1", "zstd", ] @@ -1252,7 +1279,7 @@ dependencies = [ "url", "urlencoding", "uuid", - "zip", + "zip 4.6.1", ] [[package]] @@ -1505,7 +1532,7 @@ dependencies = [ "which 8.0.5", "win32job", "windows 0.61.3", - "zip", + "zip 4.6.1", ] [[package]] @@ -1580,7 +1607,7 @@ dependencies = [ "windows-native-keyring-store", "x25519-dalek", "zbus-secret-service-keyring-store", - "zip", + "zip 4.6.1", ] [[package]] @@ -1621,7 +1648,7 @@ dependencies = [ "tracing", "url", "uuid", - "zip", + "zip 4.6.1", ] [[package]] @@ -1895,6 +1922,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calamine" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3" +dependencies = [ + "atoi_simd", + "byteorder", + "chrono", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml 0.41.0", + "serde", + "zip 8.6.0", +] + [[package]] name = "camino" version = "1.2.5" @@ -1985,7 +2030,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -2183,6 +2228,15 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -2565,6 +2619,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "1.0.11" @@ -2747,6 +2822,43 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "der" version = "0.7.10" @@ -3066,6 +3178,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -3239,6 +3360,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3308,7 +3452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom", + "nom 7.1.3", "pin-project-lite", ] @@ -3352,6 +3496,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "fastrand" version = "2.5.0" @@ -4986,6 +5136,59 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -5463,6 +5666,37 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lopdf" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d" +dependencies = [ + "aes", + "bitflags 2.11.1", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap 2.14.0", + "itoa", + "jiff", + "log", + "md-5", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror 2.0.19", + "time", + "ttf-parser", + "weezl", +] + [[package]] name = "lru" version = "0.12.5" @@ -5588,6 +5822,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "md5" version = "0.7.0" @@ -5871,6 +6115,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonmax" version = "0.5.5" @@ -7135,6 +7388,24 @@ dependencies = [ "hmac", ] +[[package]] +name = "pdf-inspector" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7475018de0880b394b7cc50f871fac0c010aa411dcd14c64074a3e640a4c05c" +dependencies = [ + "env_logger", + "include_dir", + "log", + "lopdf", + "once_cell", + "rayon", + "regex", + "thiserror 2.0.19", + "ttf-parser", + "unicode-normalization", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -7448,6 +7719,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -7699,6 +7985,7 @@ version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ + "encoding_rs", "memchr", ] @@ -7865,6 +8152,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "ratatui" version = "0.29.0" @@ -9596,6 +9889,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -10169,7 +10473,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -10266,7 +10570,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.19", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -10772,6 +11076,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" name = "tool-runtime" version = "0.2.16" dependencies = [ + "anydoc", "bitfun-agent-tools", "bitfun-events", "bitfun-runtime-ports", @@ -10788,6 +11093,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tokio", "tokio-util", "vte", @@ -10976,6 +11282,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.29.0" @@ -11003,6 +11315,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -11050,6 +11368,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -11068,6 +11392,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -12859,6 +13198,20 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 88e06a8009..6d57caa7e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" +# Document conversion +anydoc = "=0.1.6" + # TypeScript binding generation (schema-first; gated by per-crate `ts` features) ts-rs = { version = "12", features = ["serde-json-impl", "no-serde-warnings"] } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index eeea41fcc9..284b7f2ba3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,8 +1,8 @@ # Third-Party Notices -BitFun redistributes selected material from the following third-party project. -The corresponding license and provenance metadata are included with BitFun's -Desktop and CLI release packages. +BitFun redistributes selected material and links libraries from the following +third-party projects. These notices are included with BitFun's Desktop and CLI +release packages. ## models.dev catalog data @@ -21,3 +21,34 @@ complete upstream license text is preserved in `models-dev.LICENSE.txt`, which is shipped as `third-party/models.dev/LICENSE.txt`. Source distributions keep the canonical copies of both files beside the bundled snapshot under `src/crates/services/services-integrations/assets/`. + +## anydoc + +- Project: anydoc +- Source: https://github.com/firecrawl/anydoc +- Version: 0.1.6 +- License: MIT +- Copyright: Copyright (c) 2026 Sideguide Technologies Inc. + +BitFun links anydoc to convert supported office documents, OpenDocument files, +RTF, EPUB, CSV, and PDFs into Markdown for the Agent Read tool. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 8f7379b5ac..015311b2c7 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -97,6 +97,7 @@ bitfun-product-domains = { path = "../../contracts/product-domains", default-fea # Tool runtime tool-runtime = { path = "../../execution/tool-execution", default-features = false, optional = true, features = [ + "document-read", "web-readable", ] } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs index 7f646b6ab6..b7c5477a74 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs @@ -14,10 +14,15 @@ use log::{debug, warn}; use serde_json::{json, Value}; use std::convert::TryFrom; use std::path::Path; -use std::time::Instant; +use std::time::{Duration, Instant}; +use tool_runtime::fs::document::{ + convert_document_to_markdown, is_supported_document_path, DocumentConversionError, + MAX_DOCUMENT_INPUT_BYTES, MAX_DOCUMENT_MARKDOWN_BYTES, +}; use tool_runtime::fs::read_file::{ build_read_file_presentation, build_remote_read_command, build_remote_tail_read_command, - parse_remote_read_output, parse_remote_tail_read_output, read_file, read_file_tail, + parse_remote_read_output, parse_remote_tail_read_output, read_file, read_file_bytes_bounded, + read_file_tail, read_text, read_text_tail, ReadFileResult, }; pub struct FileReadTool { @@ -28,6 +33,21 @@ pub struct FileReadTool { /// Default cap on characters returned by a single Read call (excluding wrapper text). pub const DEFAULT_READ_MAX_TOTAL_CHARS: usize = 64_000; +// anydoc is synchronous, so this bounds the caller's wait rather than terminating the parser. +// The worker retains the global conversion permit until it actually exits, keeping failures closed. +const DOCUMENT_CONVERSION_TIMEOUT: Duration = Duration::from_secs(30); + +struct DocumentReadMetadata { + source_format: &'static str, + source_size_bytes: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReadRenderMode { + Auto, + Source, + Markdown, +} impl Default for FileReadTool { fn default() -> Self { @@ -95,6 +115,23 @@ impl FileReadTool { Ok(tail) } + fn read_render_mode(input: &Value) -> Result { + match input.get("render") { + None => Ok(ReadRenderMode::Auto), + Some(Value::String(value)) if value == "auto" => Ok(ReadRenderMode::Auto), + Some(Value::String(value)) if value == "source" => Ok(ReadRenderMode::Source), + Some(Value::String(value)) if value == "markdown" => Ok(ReadRenderMode::Markdown), + Some(_) => Err("render must be one of: auto, source, markdown".to_string()), + } + } + + fn path_has_csv_extension(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("csv")) + } + fn optional_line_number(input: &Value, key: &str) -> Result, String> { match input.get(key) { Some(value) => Self::line_number_from_value(value) @@ -262,6 +299,139 @@ impl FileReadTool { Ok(result) } + + async fn read_document_window( + &self, + resolved_path: &str, + logical_path: &str, + start_line: usize, + limit: usize, + tail: bool, + uses_remote_workspace_backend: bool, + context: &ToolUseContext, + ) -> BitFunResult<(ReadFileResult, DocumentReadMetadata)> { + let bytes = if uses_remote_workspace_backend { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .read_file_bounded(resolved_path, MAX_DOCUMENT_INPUT_BYTES) + .await + .map_err(|error| { + BitFunError::tool(format!( + "Failed to read document {}: {}", + logical_path, error + )) + })? + } else { + read_file_bytes_bounded(resolved_path, MAX_DOCUMENT_INPUT_BYTES) + .map_err(BitFunError::tool)? + } + .ok_or_else(|| { + BitFunError::tool(format!( + "Document {} is larger than the {} MiB Read limit", + logical_path, + MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024) + )) + })?; + + let source_size_bytes = bytes.len(); + let conversion_started_at = Instant::now(); + debug!( + "Document conversion started: path={}, source_size_bytes={}, session_id={:?}, dialog_turn_id={:?}", + logical_path, + source_size_bytes, + context.session_id, + context.dialog_turn_id + ); + let conversion = tokio::time::timeout( + DOCUMENT_CONVERSION_TIMEOUT, + convert_document_to_markdown(bytes, resolved_path.to_string()), + ) + .await + .map_err(|_| { + warn!( + "Document conversion timed out: path={}, source_size_bytes={}, timeout_ms={}, duration_ms={}", + logical_path, + source_size_bytes, + DOCUMENT_CONVERSION_TIMEOUT.as_millis(), + elapsed_ms_u64(conversion_started_at) + ); + BitFunError::tool(format!( + "Document conversion did not finish within {} seconds: {}", + DOCUMENT_CONVERSION_TIMEOUT.as_secs(), + logical_path + )) + })?; + let converted = conversion.map_err(|error| { + warn!( + "Document conversion failed: path={}, source_size_bytes={}, duration_ms={}, error_code={}, error={}", + logical_path, + source_size_bytes, + elapsed_ms_u64(conversion_started_at), + error.code(), + error + ); + Self::document_conversion_error(logical_path, resolved_path, error) + })?; + debug!( + "Document conversion completed: path={}, source_format={}, source_size_bytes={}, markdown_size_bytes={}, duration_ms={}", + logical_path, + converted.source_format, + source_size_bytes, + converted.markdown.len(), + elapsed_ms_u64(conversion_started_at) + ); + + let read_result = if tail { + read_text_tail( + &converted.markdown, + limit, + self.max_line_chars, + self.max_total_chars, + ) + } else { + read_text( + &converted.markdown, + start_line, + limit, + self.max_line_chars, + self.max_total_chars, + ) + } + .map_err(BitFunError::tool)?; + + Ok(( + read_result, + DocumentReadMetadata { + source_format: converted.source_format, + source_size_bytes, + }, + )) + } + + fn document_conversion_error( + logical_path: &str, + resolved_path: &str, + error: DocumentConversionError, + ) -> BitFunError { + let ocr_hint = (error.code() == "unsupported" + && Path::new(resolved_path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))) + .then_some( + " Text PDFs are supported, but scanned or image-only PDFs require an OCR workflow.", + ) + .unwrap_or_default(); + BitFunError::tool(format!( + "Failed to convert document {} to Markdown ({}): {}.{}", + logical_path, + error.code(), + error, + ocr_hint + )) + } } #[async_trait] @@ -272,11 +442,14 @@ impl Tool for FileReadTool { async fn description(&self) -> BitFunResult { Ok(format!( - r#"Reads a file from the current workspace filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + r#"Reads a file from the current workspace filesystem. Office documents, OpenDocument files, RTF, EPUB, and PDFs are converted locally to GitHub-Flavored Markdown before reading. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. Usage: - The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://...` URI returned by another tool. - Do not read host roots or placeholder paths such as `/workspace`. +- Supported document extensions are .doc, .docx, .docm, .ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm, .xls, .xlsx, .xlsm, .xlsb, .odt, .ods, .odp, .rtf, .epub, .csv, and .pdf. Document input is capped at {} MiB and extracted Markdown at {} MiB. Conversion is offline and never fetches linked resources. +- render defaults to auto. auto converts supported documents but preserves CSV as exact source text for editing compatibility. Use render=markdown to turn CSV into a Markdown table or to content-detect a document with a missing/wrong extension. Use render=source to bypass conversion for a textual document such as CSV or RTF. +- For converted documents, offset, limit, tail, line numbers, and total_lines refer to the extracted Markdown, not source pages or rows. The Markdown is a read-only representation; do not use it as exact source text for Edit. Embedded objects are represented by text, and scanned/image-only PDF pages require OCR. - By default, it reads up to {} lines starting from the beginning of the file. When you plan to Edit a file, prefer this default full read so you see the exact bytes you will need to match. - You can optionally specify an offset and limit. offset is a 1-based line number. Use a range only when you already know the target lines; the range must include every line you will copy into Edit `old_string`. - You can set tail=true with limit to read the last N lines. This is useful for command output and logs. Do not combine tail=true with offset. @@ -288,12 +461,16 @@ Usage: - Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window that covers the whole block you will edit. - Do not use `limit` with a small value (e.g. < 50) to probe file type or structure. Source files typically begin with copyright headers — a probe read returns no useful code. "#, - self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars + MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024), + MAX_DOCUMENT_MARKDOWN_BYTES / (1024 * 1024), + self.default_max_lines_to_read, + self.max_line_chars, + self.max_total_chars )) } fn short_description(&self) -> String { - "Read file contents.".to_string() + "Read text files and extract documents.".to_string() } fn input_schema(&self) -> Value { @@ -304,6 +481,11 @@ Usage: "type": "string", "description": "The file to read. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun:// URI returned by another tool." }, + "render": { + "type": "string", + "enum": ["auto", "source", "markdown"], + "description": "How to represent the file. auto converts supported documents but preserves CSV source text; source bypasses conversion; markdown forces local anydoc conversion and enables content detection. Defaults to auto." + }, "offset": { "type": "number", "description": "The 1-based line number to start reading from. offset=0 is accepted as offset=1. Only provide if the file is too large to read at once." @@ -367,8 +549,9 @@ Usage: } }; - if let Err(message) = - Self::read_tail_mode(input).and_then(|_| Self::read_window_start_line(input)) + if let Err(message) = Self::read_tail_mode(input) + .and_then(|_| Self::read_window_start_line(input)) + .and_then(|_| Self::read_render_mode(input)) { return ValidationResult { result: false, @@ -478,6 +661,7 @@ Usage: .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; let tail = Self::read_tail_mode(input).map_err(BitFunError::tool)?; + let render_mode = Self::read_render_mode(input).map_err(BitFunError::tool)?; let start_line = Self::read_window_start_line(input).map_err(BitFunError::tool)?; let limit = input @@ -490,7 +674,17 @@ Usage: context, &resolved.resolved_path, )?; - let revision_before_read = if resolved.uses_remote_workspace_backend() + let supported_document_path = is_supported_document_path(&resolved.logical_path) + || is_supported_document_path(&resolved.resolved_path); + let csv_path = Self::path_has_csv_extension(&resolved.logical_path) + || Self::path_has_csv_extension(&resolved.resolved_path); + let reads_document_representation = match render_mode { + ReadRenderMode::Auto => supported_document_path && !csv_path, + ReadRenderMode::Source => false, + ReadRenderMode::Markdown => true, + }; + let revision_before_read = if reads_document_representation + || resolved.uses_remote_workspace_backend() || tail || !review_read_receipts_enabled(context) { @@ -507,42 +701,69 @@ Usage: )]); } - let read_file_result = if resolved.uses_remote_workspace_backend() { + let (read_file_result, document_metadata) = if reads_document_representation { + let (result, metadata) = self + .read_document_window( + &resolved.resolved_path, + &resolved.logical_path, + start_line, + limit, + tail, + resolved.uses_remote_workspace_backend(), + context, + ) + .await?; + (result, Some(metadata)) + } else if resolved.uses_remote_workspace_backend() { if tail { - self.read_remote_tail_window(&resolved.resolved_path, limit, context) - .await? + ( + self.read_remote_tail_window(&resolved.resolved_path, limit, context) + .await?, + None, + ) } else { - self.read_remote_window(&resolved.resolved_path, start_line, limit, context) - .await? + ( + self.read_remote_window(&resolved.resolved_path, start_line, limit, context) + .await?, + None, + ) } } else if tail { - read_file_tail( - &resolved.resolved_path, - limit, - self.max_line_chars, - self.max_total_chars, + ( + read_file_tail( + &resolved.resolved_path, + limit, + self.max_line_chars, + self.max_total_chars, + ) + .map_err(BitFunError::tool)?, + None, ) - .map_err(BitFunError::tool)? } else { - read_file( - &resolved.resolved_path, - start_line, - limit, - self.max_line_chars, - self.max_total_chars, + ( + read_file( + &resolved.resolved_path, + start_line, + limit, + self.max_line_chars, + self.max_total_chars, + ) + .map_err(BitFunError::tool)?, + None, ) - .map_err(BitFunError::tool)? }; - let timestamp_ms = if resolved.uses_remote_workspace_backend() { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or(0) - } else { - local_file_modification_time_ms(Path::new(&resolved.resolved_path)) - }; - record_file_read_state(context, &resolved, &read_file_result, timestamp_ms); + if document_metadata.is_none() { + let timestamp_ms = if resolved.uses_remote_workspace_backend() { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) + } else { + local_file_modification_time_ms(Path::new(&resolved.resolved_path)) + }; + record_file_read_state(context, &resolved, &read_file_result, timestamp_ms); + } if let (Some(revision_before), Some(revision_after)) = ( revision_before_read, (!resolved.uses_remote_workspace_backend() && !tail) @@ -555,20 +776,48 @@ Usage: } let presentation = build_read_file_presentation(&resolved.logical_path, &read_file_result); + let mut result_for_assistant = presentation.result_for_assistant; + if let Some(metadata) = document_metadata.as_ref() { + let extraction_note = if metadata.source_format == "pdf" { + " OCR is not performed, so scanned or image-only pages may be omitted." + } else { + " Embedded images and objects are represented by their available text." + }; + result_for_assistant = format!( + "Converted {} from {} to GitHub-Flavored Markdown with anydoc. offset and limit refer to converted Markdown lines.{}\n\n{}", + resolved.logical_path, + metadata.source_format.to_ascii_uppercase(), + extraction_note, + result_for_assistant + ); + } + + let mut data = json!({ + "file_path": resolved.logical_path, + "content": read_file_result.content, + "total_lines": read_file_result.total_lines, + "lines_read": presentation.lines_read, + "offset": read_file_result.start_line, + "tail": tail, + "start_line": read_file_result.start_line, + "size": read_file_result.content.len(), + "hit_total_char_limit": read_file_result.hit_total_char_limit + }); + if let Some(metadata) = document_metadata { + data["representation"] = json!("extracted_markdown"); + data["source_format"] = json!(metadata.source_format); + data["source_size_bytes"] = json!(metadata.source_size_bytes); + data["conversion_engine"] = json!("anydoc"); + data["extraction_warnings"] = if metadata.source_format == "pdf" { + json!(["OCR is not performed; scanned or image-only pages may be omitted."]) + } else { + json!(["Embedded images and objects are represented by their available text."]) + }; + } let result = ToolResult::Result { - data: json!({ - "file_path": resolved.logical_path, - "content": read_file_result.content, - "total_lines": read_file_result.total_lines, - "lines_read": presentation.lines_read, - "offset": read_file_result.start_line, - "tail": tail, - "start_line": read_file_result.start_line, - "size": read_file_result.content.len(), - "hit_total_char_limit": read_file_result.hit_total_char_limit - }), - result_for_assistant: Some(presentation.result_for_assistant), + data, + result_for_assistant: Some(result_for_assistant), image_attachments: None, }; @@ -578,9 +827,128 @@ Usage: #[cfg(test)] mod tests { - use super::FileReadTool; - use crate::agentic::tools::framework::Tool; + use super::{FileReadTool, ReadRenderMode, MAX_DOCUMENT_INPUT_BYTES}; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; + use async_trait::async_trait; + use bitfun_runtime_ports::{ + ToolRuntimeHandles, WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, + WorkspaceFileSystem, WorkspaceServices, WorkspaceShell, + }; use serde_json::{json, Value}; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + fn local_context(root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some("Agent".to_string()), + session_id: None, + dialog_turn_id: Some("turn-1".to_string()), + workspace: Some(WorkspaceBinding::new( + Some("read-document-workspace".to_string()), + root, + )), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: ToolRuntimeHandles::default(), + } + } + + struct FakeRemoteFs { + bytes: Vec, + bounded_limit: Arc, + } + + #[async_trait] + impl WorkspaceFileSystem for FakeRemoteFs { + async fn read_file(&self, _path: &str) -> anyhow::Result> { + Ok(self.bytes.clone()) + } + + async fn read_file_bounded( + &self, + _path: &str, + max_bytes: usize, + ) -> anyhow::Result>> { + self.bounded_limit.store(max_bytes, Ordering::Relaxed); + Ok((self.bytes.len() <= max_bytes).then(|| self.bytes.clone())) + } + + async fn read_file_text(&self, _path: &str) -> anyhow::Result { + Ok(String::from_utf8_lossy(&self.bytes).to_string()) + } + + async fn write_file(&self, _path: &str, _contents: &[u8]) -> anyhow::Result<()> { + Ok(()) + } + + async fn exists(&self, _path: &str) -> anyhow::Result { + Ok(true) + } + + async fn is_file(&self, _path: &str) -> anyhow::Result { + Ok(true) + } + + async fn is_dir(&self, _path: &str) -> anyhow::Result { + Ok(false) + } + + async fn read_dir(&self, _path: &str) -> anyhow::Result> { + Ok(Vec::new()) + } + } + + struct PanicRemoteShell; + + #[async_trait] + impl WorkspaceShell for PanicRemoteShell { + async fn exec_with_options( + &self, + _command: &str, + _options: WorkspaceCommandOptions, + ) -> anyhow::Result { + panic!("document reads must not require a remote shell or remote anydoc install") + } + } + + fn remote_context(bytes: Vec, bounded_limit: Arc) -> ToolUseContext { + let root = "/remote/workspace"; + let session_identity = + crate::service::remote_ssh::workspace_state::workspace_session_identity( + root, + Some("conn-1"), + Some("remote-host"), + ) + .expect("remote workspace identity"); + let mut context = local_context(PathBuf::from(root)); + context.workspace = Some(WorkspaceBinding::new_remote( + Some("read-document-remote".to_string()), + PathBuf::from(root), + "conn-1".to_string(), + "remote-host".to_string(), + session_identity, + )); + context.runtime_handles = ToolRuntimeHandles::new( + Some(WorkspaceServices { + fs: Arc::new(FakeRemoteFs { + bytes, + bounded_limit, + }), + shell: Arc::new(PanicRemoteShell), + }), + None, + ); + context + } #[test] fn read_tool_schema_prefers_offset() { @@ -592,6 +960,10 @@ mod tests { assert!(properties.contains_key("offset")); assert!(properties.contains_key("tail")); + assert_eq!( + properties["render"]["enum"], + json!(["auto", "source", "markdown"]) + ); } #[test] @@ -620,4 +992,125 @@ mod tests { assert_eq!(error, "Do not provide offset when tail is true"); } + + #[test] + fn read_render_mode_defaults_to_auto_and_rejects_unknown_values() { + assert_eq!( + FileReadTool::read_render_mode(&json!({})).expect("default render"), + ReadRenderMode::Auto + ); + assert_eq!( + FileReadTool::read_render_mode(&json!({ "render": "source" })).expect("source render"), + ReadRenderMode::Source + ); + assert!(FileReadTool::read_render_mode(&json!({ "render": "html" })).is_err()); + assert!(FileReadTool::read_render_mode(&json!({ "render": 1 })).is_err()); + } + + #[tokio::test] + async fn read_converts_rtf_to_a_markdown_representation() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = br"{\rtf1\ansi Hello from the document}"; + fs::write(dir.path().join("notes.rtf"), source).expect("write RTF"); + + let results = FileReadTool::new() + .call_impl( + &json!({ "file_path": "notes.rtf" }), + &local_context(dir.path().to_path_buf()), + ) + .await + .expect("document read should succeed"); + + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected result"); + }; + assert_eq!(data["representation"], "extracted_markdown"); + assert_eq!(data["source_format"], "rtf"); + assert_eq!(data["conversion_engine"], "anydoc"); + assert_eq!(data["source_size_bytes"], source.len()); + assert!(data["content"] + .as_str() + .is_some_and(|content| content.contains("Hello from the document"))); + assert!(result_for_assistant + .as_deref() + .is_some_and(|result| result.contains("from RTF to GitHub-Flavored Markdown"))); + } + + #[tokio::test] + async fn csv_auto_preserves_source_while_markdown_render_extracts_a_table() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write( + dir.path().join("table.csv"), + "name,value\nalpha,1\nbeta,2\n", + ) + .expect("write CSV"); + let context = local_context(dir.path().to_path_buf()); + let tool = FileReadTool::new(); + + let auto = tool + .call_impl(&json!({ "file_path": "table.csv" }), &context) + .await + .expect("source read should succeed"); + let markdown = tool + .call_impl( + &json!({ "file_path": "table.csv", "render": "markdown" }), + &context, + ) + .await + .expect("Markdown read should succeed"); + + let ToolResult::Result { + data: auto_data, .. + } = &auto[0] + else { + panic!("expected source result"); + }; + let ToolResult::Result { + data: markdown_data, + .. + } = &markdown[0] + else { + panic!("expected Markdown result"); + }; + assert!(auto_data.get("representation").is_none()); + assert!(auto_data["content"] + .as_str() + .is_some_and(|content| content.contains("name,value"))); + assert_eq!(markdown_data["representation"], "extracted_markdown"); + assert_eq!(markdown_data["source_format"], "csv"); + assert!(markdown_data["content"] + .as_str() + .is_some_and(|content| content.contains("| name | value |"))); + } + + #[tokio::test] + async fn remote_document_uses_bounded_file_transfer_and_host_side_conversion() { + let bounded_limit = Arc::new(AtomicUsize::new(0)); + let context = remote_context( + br"{\rtf1\ansi Hello from remote RTF}".to_vec(), + Arc::clone(&bounded_limit), + ); + + let results = FileReadTool::new() + .call_impl(&json!({ "file_path": "notes.rtf" }), &context) + .await + .expect("remote document read should succeed"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result"); + }; + assert_eq!( + bounded_limit.load(Ordering::Relaxed), + MAX_DOCUMENT_INPUT_BYTES + ); + assert_eq!(data["representation"], "extracted_markdown"); + assert!(data["content"] + .as_str() + .is_some_and(|content| content.contains("Hello from remote RTF"))); + } } diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 788a699ad7..a3e065ae28 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -305,6 +305,19 @@ pub enum WorkspacePathKind { #[async_trait::async_trait] pub trait WorkspaceFileSystem: Send + Sync { async fn read_file(&self, path: &str) -> anyhow::Result>; + /// Read binary content up to `max_bytes`. + /// + /// `Ok(None)` means the file exceeded the bound; missing paths, non-files, and transport + /// failures remain errors. Production providers should enforce the bound before or while + /// transferring the file. + async fn read_file_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result>> { + let bytes = self.read_file(path).await?; + Ok((bytes.len() <= max_bytes).then_some(bytes)) + } async fn read_file_text(&self, path: &str) -> anyhow::Result; /// Read UTF-8 text up to `max_bytes`. Production filesystem providers /// should enforce the bound before or while transferring the file. diff --git a/src/crates/execution/tool-execution/AGENTS.md b/src/crates/execution/tool-execution/AGENTS.md index d2a4bd3db8..2fc1b2aecc 100644 --- a/src/crates/execution/tool-execution/AGENTS.md +++ b/src/crates/execution/tool-execution/AGENTS.md @@ -36,6 +36,7 @@ agent-facing tool surface. ```bash cargo test -p tool-runtime +cargo test -p tool-runtime --features document-read fs::document cargo test -p tool-runtime --features web-readable web node scripts/check-core-boundaries.mjs ``` diff --git a/src/crates/execution/tool-execution/Cargo.toml b/src/crates/execution/tool-execution/Cargo.toml index 74dec05264..b83d183315 100644 --- a/src/crates/execution/tool-execution/Cargo.toml +++ b/src/crates/execution/tool-execution/Cargo.toml @@ -5,9 +5,11 @@ edition.workspace = true [features] default = [] +document-read = ["dep:anydoc", "dep:sha2"] web-readable = ["dep:htmd", "dep:legible", "dep:readability-js", "dep:regex"] [dependencies] +anydoc = { workspace = true, optional = true } bitfun-agent-tools = { path = "../tool-contracts" } bitfun-events = { path = "../../contracts/events" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } @@ -23,6 +25,7 @@ log = { workspace = true } regex = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true, optional = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } tokio-util = { workspace = true } vte = { workspace = true, features = ["ansi"] } diff --git a/src/crates/execution/tool-execution/src/fs/document.rs b/src/crates/execution/tool-execution/src/fs/document.rs new file mode 100644 index 0000000000..62112ec30e --- /dev/null +++ b/src/crates/execution/tool-execution/src/fs/document.rs @@ -0,0 +1,286 @@ +//! Local, provider-neutral document-to-Markdown conversion for the Read tool. + +use std::collections::VecDeque; +use std::fmt; +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; + +use anydoc::Format; +use sha2::{Digest, Sha256}; +use tokio::sync::Semaphore; + +/// Maximum source-document size accepted by the Read tool conversion path. +pub const MAX_DOCUMENT_INPUT_BYTES: usize = 64 * 1024 * 1024; + +/// Maximum retained Markdown for one conversion and across the in-memory conversion cache. +pub const MAX_DOCUMENT_MARKDOWN_BYTES: usize = 16 * 1024 * 1024; + +const MAX_DOCUMENT_CACHE_ENTRIES: usize = 4; + +/// A document representation that can be paged by the normal Read primitives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConvertedDocument { + pub markdown: Arc, + pub source_format: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DocumentCacheKey { + source_sha256: [u8; 32], + format: Format, +} + +struct DocumentCacheEntry { + key: DocumentCacheKey, + document: ConvertedDocument, +} + +#[derive(Default)] +struct DocumentCache { + entries: VecDeque, + retained_markdown_bytes: usize, +} + +impl DocumentCache { + fn get(&mut self, key: DocumentCacheKey) -> Option { + let index = self.entries.iter().position(|entry| entry.key == key)?; + let entry = self.entries.remove(index)?; + let document = entry.document.clone(); + self.entries.push_back(entry); + Some(document) + } + + fn insert(&mut self, key: DocumentCacheKey, document: ConvertedDocument) { + let markdown_bytes = document.markdown.len(); + if markdown_bytes > MAX_DOCUMENT_MARKDOWN_BYTES { + return; + } + + while self.entries.len() >= MAX_DOCUMENT_CACHE_ENTRIES + || self.retained_markdown_bytes.saturating_add(markdown_bytes) + > MAX_DOCUMENT_MARKDOWN_BYTES + { + let Some(evicted) = self.entries.pop_front() else { + break; + }; + self.retained_markdown_bytes = self + .retained_markdown_bytes + .saturating_sub(evicted.document.markdown.len()); + } + + self.retained_markdown_bytes = self.retained_markdown_bytes.saturating_add(markdown_bytes); + self.entries.push_back(DocumentCacheEntry { key, document }); + } +} + +/// Provider-neutral document conversion failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentConversionError { + code: &'static str, + message: String, +} + +impl DocumentConversionError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &'static str { + self.code + } +} + +impl fmt::Display for DocumentConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for DocumentConversionError {} + +/// Whether the path extension names a format handled by anydoc. +pub fn is_supported_document_path(path: &str) -> bool { + Format::from_path(Path::new(path)).is_some() +} + +/// Convert document bytes on the blocking pool. Conversion is serialized process-wide because +/// parsers can temporarily retain substantially more decompressed data than the source file. +pub async fn convert_document_to_markdown( + bytes: Vec, + path_hint: String, +) -> Result { + if bytes.len() > MAX_DOCUMENT_INPUT_BYTES { + return Err(DocumentConversionError::new( + "resourceLimit", + format!( + "document is larger than the {} MiB Read limit", + MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024) + ), + )); + } + + let permit = document_conversion_semaphore() + .clone() + .acquire_owned() + .await + .map_err(|_| { + DocumentConversionError::new( + "runtime", + "document conversion is unavailable because its worker was closed", + ) + })?; + + tokio::task::spawn_blocking(move || { + // Keep the permit inside the blocking task. If the async caller is cancelled, the parser + // still occupies its bounded slot until the synchronous conversion actually exits. + let _permit = permit; + convert_document_to_markdown_sync(&bytes, &path_hint) + }) + .await + .map_err(|error| { + DocumentConversionError::new( + "runtime", + format!("document conversion worker failed: {error}"), + ) + })? +} + +fn document_conversion_semaphore() -> &'static Arc { + static SEMAPHORE: OnceLock> = OnceLock::new(); + SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(1))) +} + +fn convert_document_to_markdown_sync( + bytes: &[u8], + path_hint: &str, +) -> Result { + let format = Format::from_bytes(bytes) + .or_else(|| Format::from_path(Path::new(path_hint))) + .ok_or_else(|| { + DocumentConversionError::new( + "unsupported", + "file content and extension do not identify a supported document format", + ) + })?; + let cache_key = DocumentCacheKey { + source_sha256: Sha256::digest(bytes).into(), + format, + }; + if let Some(document) = document_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(cache_key) + { + return Ok(document); + } + + let source_format = format_name(format); + let markdown = anydoc::to_markdown_bytes(bytes, format) + .map_err(|error| DocumentConversionError::new(error.code(), error.to_string()))?; + if markdown.len() > MAX_DOCUMENT_MARKDOWN_BYTES { + return Err(DocumentConversionError::new( + "resourceLimit", + format!( + "converted Markdown is larger than the {} MiB Read limit", + MAX_DOCUMENT_MARKDOWN_BYTES / (1024 * 1024) + ), + )); + } + + let document = ConvertedDocument { + markdown: Arc::from(markdown), + source_format, + }; + document_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(cache_key, document.clone()); + Ok(document) +} + +fn document_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(DocumentCache::default())) +} + +fn format_name(format: Format) -> &'static str { + match format { + Format::Doc => "doc", + Format::Docx => "docx", + Format::Odt => "odt", + Format::Pdf => "pdf", + Format::Ppt => "ppt", + Format::Pptx => "pptx", + Format::Rtf => "rtf", + Format::Epub => "epub", + Format::Excel => "excel", + Format::Ods => "ods", + Format::Odp => "odp", + Format::Csv => "csv", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_all_supported_extension_families() { + for path in [ + "report.doc", + "report.DOCX", + "report.docm", + "slides.ppt", + "slides.ppsx", + "sheet.xlsb", + "sheet.xlsx", + "document.odt", + "sheet.ods", + "slides.odp", + "notes.rtf", + "book.epub", + "table.csv", + "paper.pdf", + ] { + assert!(is_supported_document_path(path), "{path}"); + } + assert!(!is_supported_document_path("src/lib.rs")); + assert!(!is_supported_document_path("README.md")); + } + + #[test] + fn content_detection_takes_precedence_over_a_wrong_extension_hint() { + let converted = + convert_document_to_markdown_sync(br"{\rtf1\ansi Hello from RTF}", "mislabelled.pdf") + .expect("RTF should convert"); + + assert_eq!(converted.source_format, "rtf"); + assert!(converted.markdown.contains("Hello from RTF")); + } + + #[test] + fn csv_uses_the_path_hint_because_it_has_no_content_signature() { + let converted = + convert_document_to_markdown_sync(b"name,value\nalpha,1\nbeta,2\n", "table.csv") + .expect("CSV should convert"); + + assert_eq!(converted.source_format, "csv"); + assert!(converted.markdown.contains("| name | value |")); + assert!(converted.markdown.contains("| alpha | 1 |")); + } + + #[test] + fn repeated_conversion_reuses_cached_markdown_for_offset_reads() { + let first = + convert_document_to_markdown_sync(br"{\rtf1\ansi Cached document}", "cached.rtf") + .expect("first conversion"); + let second = + convert_document_to_markdown_sync(br"{\rtf1\ansi Cached document}", "cached.rtf") + .expect("second conversion"); + + assert!(Arc::ptr_eq(&first.markdown, &second.markdown)); + } +} diff --git a/src/crates/execution/tool-execution/src/fs/mod.rs b/src/crates/execution/tool-execution/src/fs/mod.rs index 28dbbb346f..a51bb0690e 100644 --- a/src/crates/execution/tool-execution/src/fs/mod.rs +++ b/src/crates/execution/tool-execution/src/fs/mod.rs @@ -1,5 +1,7 @@ pub mod backend; pub mod delete_path; +#[cfg(feature = "document-read")] +pub mod document; pub mod edit_file; pub mod list_dir; pub mod read_file; diff --git a/src/crates/execution/tool-execution/src/fs/read_file.rs b/src/crates/execution/tool-execution/src/fs/read_file.rs index 131626439b..70ec5cd5a3 100644 --- a/src/crates/execution/tool-execution/src/fs/read_file.rs +++ b/src/crates/execution/tool-execution/src/fs/read_file.rs @@ -1,8 +1,7 @@ use crate::util::string::{shell_single_quote, truncate_string_by_chars}; use std::collections::VecDeque; use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; +use std::io::{BufRead, BufReader, Read}; const REMOTE_TOTAL_LINES_MARKER: &str = "__BITFUN_TOTAL_LINES__="; const REMOTE_HIT_TOTAL_CHAR_LIMIT_MARKER: &str = "__BITFUN_HIT_TOTAL_CHAR_LIMIT__="; @@ -61,6 +60,30 @@ pub fn build_read_file_presentation( } } +/// Read a local file only when it fits within `max_bytes`. +/// +/// The limit is enforced from handle metadata and again while reading so a growing file cannot +/// make the caller retain an unbounded buffer. +pub fn read_file_bytes_bounded( + file_path: &str, + max_bytes: usize, +) -> Result>, String> { + let file = File::open(file_path) + .map_err(|error| format!("Failed to read file {file_path}: {error}"))?; + let metadata = file + .metadata() + .map_err(|error| format!("Failed to inspect file {file_path}: {error}"))?; + if metadata.len() > max_bytes as u64 { + return Ok(None); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(max_bytes.saturating_add(1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("Failed to read file {file_path}: {error}"))?; + Ok((bytes.len() <= max_bytes).then_some(bytes)) +} + pub fn build_remote_read_command( resolved_path: &str, start_line: usize, @@ -220,6 +243,45 @@ pub fn read_file( limit: usize, max_line_chars: usize, max_total_chars: usize, +) -> Result { + let file = + File::open(file_path).map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + let source = format!("file {file_path}"); + read_buffered_text( + BufReader::new(file), + &source, + start_line, + limit, + max_line_chars, + max_total_chars, + ) +} + +/// Page already-decoded text with the same line numbering and budgets as [`read_file`]. +pub fn read_text( + text: &str, + start_line: usize, + limit: usize, + max_line_chars: usize, + max_total_chars: usize, +) -> Result { + read_buffered_text( + BufReader::new(text.as_bytes()), + "converted document", + start_line, + limit, + max_line_chars, + max_total_chars, + ) +} + +fn read_buffered_text( + reader: R, + source: &str, + start_line: usize, + limit: usize, + max_line_chars: usize, + max_total_chars: usize, ) -> Result { if start_line == 0 { return Err("`start_line` should start from 1".to_string()); @@ -234,17 +296,13 @@ pub fn read_file( .checked_add(limit.saturating_sub(1)) .ok_or_else(|| "Requested line range is too large".to_string())?; - let file = - File::open(file_path).map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; - let reader = BufReader::new(file); - let mut total_lines = 0usize; let mut selected_lines = Vec::new(); let mut selected_chars = 0usize; let mut hit_total_char_limit = false; for line_result in reader.lines() { - let line = line_result.map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + let line = line_result.map_err(|e| format!("Failed to read {source}: {e}"))?; total_lines += 1; if total_lines < start_line || total_lines > end_line_inclusive || hit_total_char_limit { @@ -312,6 +370,41 @@ pub fn read_file_tail( limit: usize, max_line_chars: usize, max_total_chars: usize, +) -> Result { + let file = + File::open(file_path).map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + let source = format!("file {file_path}"); + read_buffered_text_tail( + BufReader::new(file), + &source, + limit, + max_line_chars, + max_total_chars, + ) +} + +/// Read the last lines of already-decoded text with the same budgets as [`read_file_tail`]. +pub fn read_text_tail( + text: &str, + limit: usize, + max_line_chars: usize, + max_total_chars: usize, +) -> Result { + read_buffered_text_tail( + BufReader::new(text.as_bytes()), + "converted document", + limit, + max_line_chars, + max_total_chars, + ) +} + +fn read_buffered_text_tail( + reader: R, + source: &str, + limit: usize, + max_line_chars: usize, + max_total_chars: usize, ) -> Result { if limit == 0 { return Err("`limit` can't be 0".to_string()); @@ -320,15 +413,11 @@ pub fn read_file_tail( return Err("`max_total_chars` can't be 0".to_string()); } - let file = - File::open(file_path).map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; - let reader = BufReader::new(file); - let mut total_lines = 0usize; let mut tail_lines = VecDeque::with_capacity(limit); for line_result in reader.lines() { - let line = line_result.map_err(|e| format!("Failed to read file {}: {}", file_path, e))?; + let line = line_result.map_err(|e| format!("Failed to read {source}: {e}"))?; total_lines += 1; if tail_lines.len() == limit { @@ -397,8 +486,8 @@ pub fn read_file_tail( #[cfg(test)] mod tests { use super::{ - build_read_file_presentation, read_file, read_file_lines_read, read_file_tail, - ReadFileResult, + build_read_file_presentation, read_file, read_file_bytes_bounded, read_file_lines_read, + read_file_tail, read_text, read_text_tail, ReadFileResult, }; use std::fs; use std::path::PathBuf; @@ -450,6 +539,37 @@ mod tests { assert_eq!(result.content, " 1\tone\n 2\ttwo\n 3\tthree"); } + #[test] + fn bounded_byte_read_rejects_a_file_before_returning_oversized_content() { + let path = write_temp_file("12345"); + + let rejected = read_file_bytes_bounded(path.to_str().expect("utf-8 path"), 4) + .expect("bounded read should succeed"); + let accepted = read_file_bytes_bounded(path.to_str().expect("utf-8 path"), 5) + .expect("bounded read should succeed"); + + fs::remove_file(&path).expect("temp file should be deleted"); + + assert!(rejected.is_none()); + assert_eq!(accepted, Some(b"12345".to_vec())); + } + + #[test] + fn decoded_text_reuses_normal_window_and_tail_semantics() { + let text = "one\ntwo\nthree\nfour\n"; + + let window = read_text(text, 2, 2, 50, 100).expect("window should read"); + let tail = read_text_tail(text, 2, 50, 100).expect("tail should read"); + + assert_eq!(window.start_line, 2); + assert_eq!(window.end_line, 3); + assert_eq!(window.total_lines, 4); + assert_eq!(window.content, " 2\ttwo\n 3\tthree"); + assert_eq!(tail.start_line, 3); + assert_eq!(tail.end_line, 4); + assert_eq!(tail.content, " 3\tthree\n 4\tfour"); + } + #[test] fn read_file_presentation_reports_continuation_window() { let result = ReadFileResult { diff --git a/src/crates/services/services-core/src/workspace.rs b/src/crates/services/services-core/src/workspace.rs index 73489dd6b4..26871e493e 100644 --- a/src/crates/services/services-core/src/workspace.rs +++ b/src/crates/services/services-core/src/workspace.rs @@ -22,15 +22,11 @@ impl WorkspaceFileSystem for LocalWorkspaceFs { Ok(tokio::fs::read(path).await?) } - async fn read_file_text(&self, path: &str) -> anyhow::Result { - Ok(tokio::fs::read_to_string(path).await?) - } - - async fn read_file_text_bounded( + async fn read_file_bounded( &self, path: &str, max_bytes: usize, - ) -> anyhow::Result> { + ) -> anyhow::Result>> { let metadata = tokio::fs::metadata(path).await?; if metadata.len() > max_bytes as u64 { return Ok(None); @@ -38,13 +34,26 @@ impl WorkspaceFileSystem for LocalWorkspaceFs { let mut bytes = Vec::with_capacity(metadata.len() as usize); tokio::fs::File::open(path) .await? - .take(max_bytes as u64 + 1) + .take(max_bytes.saturating_add(1) as u64) .read_to_end(&mut bytes) .await?; - if bytes.len() > max_bytes { - return Ok(None); - } - Ok(Some(String::from_utf8(bytes)?)) + Ok((bytes.len() <= max_bytes).then_some(bytes)) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + Ok(tokio::fs::read_to_string(path).await?) + } + + async fn read_file_text_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result> { + self.read_file_bounded(path, max_bytes) + .await? + .map(String::from_utf8) + .transpose() + .map_err(Into::into) } async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { @@ -270,6 +279,11 @@ mod tests { assert!(fs.exists(&path).await.unwrap()); assert!(fs.is_file(&path).await.unwrap()); assert_eq!(fs.read_file_text(&path).await.unwrap(), "hello"); + assert!(fs.read_file_bounded(&path, 4).await.unwrap().is_none()); + assert_eq!( + fs.read_file_bounded(&path, 5).await.unwrap(), + Some(b"hello".to_vec()) + ); } #[tokio::test] diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs index 451d055602..11d0fb49d4 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs @@ -11,9 +11,40 @@ use bitfun_runtime_ports::{ }; use std::sync::Arc; -use super::{RemoteFileService, SSHCommandOptions, SSHCommandResult, SSHConnectionManager}; +use super::{ + RemoteFileEntry, RemoteFileService, SSHCommandOptions, SSHCommandResult, SSHConnectionManager, +}; use crate::remote_ssh::shell; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BoundedReadPreflight { + Transfer, + TooLarge, +} + +fn bounded_read_preflight( + path: &str, + entry: Option<&RemoteFileEntry>, + max_bytes: usize, +) -> anyhow::Result { + let Some(entry) = entry else { + // Let the actual transfer report a precise missing-file or transport error. + return Ok(BoundedReadPreflight::Transfer); + }; + if entry.is_symlink { + // SFTP stat follows the final symlink while container metadata does not. The transfer + // follows it in both backends, and the progress callback still enforces the byte limit. + return Ok(BoundedReadPreflight::Transfer); + } + if !entry.is_file { + anyhow::bail!("Remote path is not a file: {path}"); + } + if entry.size.is_some_and(|size| size > max_bytes as u64) { + return Ok(BoundedReadPreflight::TooLarge); + } + Ok(BoundedReadPreflight::Transfer) +} + /// SSH-backed filesystem implementation of [`WorkspaceFileSystem`]. pub struct RemoteWorkspaceFs { connection_id: String, @@ -27,6 +58,28 @@ impl RemoteWorkspaceFs { file_service, } } + + async fn transfer_file_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result>> { + let mut exceeded = false; + let bytes = match self + .file_service + .read_file_with_progress(&self.connection_id, path, &mut |bytes_read, total_size| { + let over_limit = bytes_read > max_bytes as u64 || total_size > max_bytes as u64; + exceeded |= over_limit; + !over_limit + }) + .await + { + Ok(bytes) => bytes, + Err(_) if exceeded => return Ok(None), + Err(error) => return Err(error), + }; + Ok((bytes.len() <= max_bytes).then_some(bytes)) + } } #[async_trait] @@ -35,6 +88,20 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { self.file_service.read_file(&self.connection_id, path).await } + async fn read_file_bounded( + &self, + path: &str, + max_bytes: usize, + ) -> anyhow::Result>> { + let entry = self.file_service.stat(&self.connection_id, path).await?; + if bounded_read_preflight(path, entry.as_ref(), max_bytes)? + == BoundedReadPreflight::TooLarge + { + return Ok(None); + } + self.transfer_file_bounded(path, max_bytes).await + } + async fn read_file_text(&self, path: &str) -> anyhow::Result { let bytes = self.read_file(path).await?; Ok(String::from_utf8_lossy(&bytes).to_string()) @@ -55,24 +122,10 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { if !entry.is_file || entry.size.is_some_and(|size| size > max_bytes as u64) { return Ok(None); } - let mut exceeded = false; - let bytes = match self - .file_service - .read_file_with_progress(&self.connection_id, path, &mut |bytes_read, total_size| { - let over_limit = bytes_read > max_bytes as u64 || total_size > max_bytes as u64; - exceeded |= over_limit; - !over_limit - }) - .await - { - Ok(bytes) => bytes, - Err(_) if exceeded => return Ok(None), - Err(error) => return Err(error), - }; - if bytes.len() > max_bytes { - return Ok(None); - } - Ok(Some(String::from_utf8_lossy(&bytes).to_string())) + Ok(self + .transfer_file_bounded(path, max_bytes) + .await? + .map(|bytes| String::from_utf8_lossy(&bytes).to_string())) } async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { @@ -156,6 +209,43 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { } } +#[cfg(test)] +mod bounded_read_tests { + use super::*; + + fn entry(is_file: bool, is_symlink: bool, size: Option) -> RemoteFileEntry { + RemoteFileEntry { + name: "document.docx".to_string(), + path: "/workspace/document.docx".to_string(), + is_dir: !is_file && !is_symlink, + is_file, + is_symlink, + size, + modified: None, + permissions: None, + } + } + + #[test] + fn bounded_binary_preflight_preserves_errors_and_follows_document_symlinks() { + assert_eq!( + bounded_read_preflight("missing.docx", None, 64).unwrap(), + BoundedReadPreflight::Transfer + ); + assert_eq!( + bounded_read_preflight("linked.docx", Some(&entry(false, true, Some(4))), 64).unwrap(), + BoundedReadPreflight::Transfer + ); + assert_eq!( + bounded_read_preflight("large.docx", Some(&entry(true, false, Some(65))), 64).unwrap(), + BoundedReadPreflight::TooLarge + ); + assert!( + bounded_read_preflight("folder.docx", Some(&entry(false, false, None)), 64).is_err() + ); + } +} + /// SSH-backed shell implementation of [`WorkspaceShell`]. pub struct RemoteWorkspaceShell { ssh_manager: SSHConnectionManager, From 840fd500762714643d649bd034f86e7bb694a5ea Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 04:45:48 -0700 Subject: [PATCH 015/206] fix(skills): remove redistribution-restricted builtins Stop embedding the docx, pdf, pptx, and xlsx skill bundles, clean legacy loose installs, and keep the supported ppt-design workflow. Also restore the Superpowers MIT notice for the retained writing-skills content while dropping its vendored Anthropic documentation copy. --- README.md | 4 +- README.zh-CN.md | 4 +- .../core/builtin-skills-upstreams.json | 12 - .../core/builtin_skills/docx/LICENSE.txt | 30 - .../core/builtin_skills/docx/SKILL.md | 92 - .../builtin_skills/docx/scripts/__init__.py | 1 - .../docx/scripts/accept_changes.py | 135 - .../builtin_skills/docx/scripts/comment.py | 368 -- .../builtin_skills/docx/scripts/merge_runs.py | 310 -- .../docx/scripts/office/helpers/__init__.py | 150 - .../docx/scripts/office/helpers/pptx_chart.py | 170 - .../docx/scripts/office/helpers/pptx_slide.py | 60 - .../docx/scripts/office/helpers/pptx_theme.py | 114 - .../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ------ .../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 - .../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ---- .../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 - .../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ------------ .../ISO-IEC29500-4_2016/dml-picture.xsd | 23 - .../dml-spreadsheetDrawing.xsd | 185 - .../dml-wordprocessingDrawing.xsd | 287 -- .../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 ------- .../shared-additionalCharacteristics.xsd | 28 - .../shared-bibliography.xsd | 144 - .../shared-commonSimpleTypes.xsd | 174 - .../shared-customXmlDataProperties.xsd | 25 - .../shared-customXmlSchemaProperties.xsd | 18 - .../shared-documentPropertiesCustom.xsd | 59 - .../shared-documentPropertiesExtended.xsd | 56 - .../shared-documentPropertiesVariantTypes.xsd | 195 - .../ISO-IEC29500-4_2016/shared-math.xsd | 582 --- .../shared-relationshipReference.xsd | 25 - .../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 ----------------- .../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 --- .../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 -- .../vml-presentationDrawing.xsd | 12 - .../vml-spreadsheetDrawing.xsd | 108 - .../vml-wordprocessingDrawing.xsd | 96 - .../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 -------------- .../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 - .../ecma/fouth-edition/opc-contentTypes.xsd | 42 - .../ecma/fouth-edition/opc-coreProperties.xsd | 50 - .../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 - .../ecma/fouth-edition/opc-relationships.xsd | 33 - .../docx/scripts/office/schemas/mce/mc.xsd | 75 - .../office/schemas/microsoft/wml-2010.xsd | 560 --- .../office/schemas/microsoft/wml-2012.xsd | 67 - .../office/schemas/microsoft/wml-2018.xsd | 14 - .../office/schemas/microsoft/wml-cex-2018.xsd | 20 - .../office/schemas/microsoft/wml-cid-2016.xsd | 13 - .../microsoft/wml-sdtdatahash-2020.xsd | 4 - .../schemas/microsoft/wml-symex-2015.xsd | 8 - .../docx/scripts/office/soffice.py | 192 - .../docx/scripts/office/validate.py | 173 - .../scripts/office/validators/__init__.py | 15 - .../docx/scripts/office/validators/base.py | 875 ---- .../docx/scripts/office/validators/docx.py | 466 -- .../docx/scripts/office/validators/pptx.py | 441 -- .../scripts/office/validators/redlining.py | 299 -- .../docx/scripts/templates/comments.xml | 3 - .../scripts/templates/commentsExtended.xml | 3 - .../scripts/templates/commentsExtensible.xml | 3 - .../docx/scripts/templates/commentsIds.xml | 3 - .../docx/scripts/templates/people.xml | 3 - .../core/builtin_skills/pdf/LICENSE.txt | 30 - .../assembly/core/builtin_skills/pdf/SKILL.md | 314 -- .../assembly/core/builtin_skills/pdf/forms.md | 294 -- .../core/builtin_skills/pdf/reference.md | 612 --- .../pdf/scripts/check_bounding_boxes.py | 65 - .../pdf/scripts/check_fillable_fields.py | 11 - .../pdf/scripts/convert_pdf_to_images.py | 33 - .../pdf/scripts/create_validation_image.py | 37 - .../pdf/scripts/extract_form_field_info.py | 122 - .../pdf/scripts/extract_form_structure.py | 115 - .../pdf/scripts/fill_fillable_fields.py | 98 - .../scripts/fill_pdf_form_with_annotations.py | 107 - .../core/builtin_skills/pptx/LICENSE.txt | 30 - .../core/builtin_skills/pptx/SKILL.md | 238 - .../builtin_skills/pptx/scripts/__init__.py | 0 .../builtin_skills/pptx/scripts/add_slide.py | 367 -- .../core/builtin_skills/pptx/scripts/clean.py | 309 -- .../pptx/scripts/office/helpers/__init__.py | 150 - .../pptx/scripts/office/helpers/pptx_chart.py | 170 - .../pptx/scripts/office/helpers/pptx_slide.py | 60 - .../pptx/scripts/office/helpers/pptx_theme.py | 114 - .../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ------ .../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 - .../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ---- .../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 - .../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ------------ .../ISO-IEC29500-4_2016/dml-picture.xsd | 23 - .../dml-spreadsheetDrawing.xsd | 185 - .../dml-wordprocessingDrawing.xsd | 287 -- .../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 ------- .../shared-additionalCharacteristics.xsd | 28 - .../shared-bibliography.xsd | 144 - .../shared-commonSimpleTypes.xsd | 174 - .../shared-customXmlDataProperties.xsd | 25 - .../shared-customXmlSchemaProperties.xsd | 18 - .../shared-documentPropertiesCustom.xsd | 59 - .../shared-documentPropertiesExtended.xsd | 56 - .../shared-documentPropertiesVariantTypes.xsd | 195 - .../ISO-IEC29500-4_2016/shared-math.xsd | 582 --- .../shared-relationshipReference.xsd | 25 - .../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 ----------------- .../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 --- .../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 -- .../vml-presentationDrawing.xsd | 12 - .../vml-spreadsheetDrawing.xsd | 108 - .../vml-wordprocessingDrawing.xsd | 96 - .../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 -------------- .../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 - .../ecma/fouth-edition/opc-contentTypes.xsd | 42 - .../ecma/fouth-edition/opc-coreProperties.xsd | 50 - .../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 - .../ecma/fouth-edition/opc-relationships.xsd | 33 - .../pptx/scripts/office/schemas/mce/mc.xsd | 75 - .../office/schemas/microsoft/wml-2010.xsd | 560 --- .../office/schemas/microsoft/wml-2012.xsd | 67 - .../office/schemas/microsoft/wml-2018.xsd | 14 - .../office/schemas/microsoft/wml-cex-2018.xsd | 20 - .../office/schemas/microsoft/wml-cid-2016.xsd | 13 - .../microsoft/wml-sdtdatahash-2020.xsd | 4 - .../schemas/microsoft/wml-symex-2015.xsd | 8 - .../pptx/scripts/office/soffice.py | 192 - .../pptx/scripts/office/validate.py | 173 - .../scripts/office/validators/__init__.py | 15 - .../pptx/scripts/office/validators/base.py | 875 ---- .../pptx/scripts/office/validators/docx.py | 466 -- .../pptx/scripts/office/validators/pptx.py | 441 -- .../scripts/office/validators/redlining.py | 299 -- .../builtin_skills/pptx/scripts/thumbnail.py | 311 -- .../builtin_skills/writing-skills/LICENSE.txt | 21 + .../anthropic-best-practices.md | 1150 ----- .../core/builtin_skills/xlsx/LICENSE.txt | 30 - .../core/builtin_skills/xlsx/SKILL.md | 99 - .../xlsx/scripts/office/helpers/__init__.py | 150 - .../xlsx/scripts/office/helpers/pptx_chart.py | 170 - .../xlsx/scripts/office/helpers/pptx_slide.py | 60 - .../xlsx/scripts/office/helpers/pptx_theme.py | 114 - .../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ------ .../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 - .../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ---- .../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 - .../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ------------ .../ISO-IEC29500-4_2016/dml-picture.xsd | 23 - .../dml-spreadsheetDrawing.xsd | 185 - .../dml-wordprocessingDrawing.xsd | 287 -- .../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 ------- .../shared-additionalCharacteristics.xsd | 28 - .../shared-bibliography.xsd | 144 - .../shared-commonSimpleTypes.xsd | 174 - .../shared-customXmlDataProperties.xsd | 25 - .../shared-customXmlSchemaProperties.xsd | 18 - .../shared-documentPropertiesCustom.xsd | 59 - .../shared-documentPropertiesExtended.xsd | 56 - .../shared-documentPropertiesVariantTypes.xsd | 195 - .../ISO-IEC29500-4_2016/shared-math.xsd | 582 --- .../shared-relationshipReference.xsd | 25 - .../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 ----------------- .../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 --- .../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 -- .../vml-presentationDrawing.xsd | 12 - .../vml-spreadsheetDrawing.xsd | 108 - .../vml-wordprocessingDrawing.xsd | 96 - .../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 -------------- .../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 - .../ecma/fouth-edition/opc-contentTypes.xsd | 42 - .../ecma/fouth-edition/opc-coreProperties.xsd | 50 - .../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 - .../ecma/fouth-edition/opc-relationships.xsd | 33 - .../xlsx/scripts/office/schemas/mce/mc.xsd | 75 - .../office/schemas/microsoft/wml-2010.xsd | 560 --- .../office/schemas/microsoft/wml-2012.xsd | 67 - .../office/schemas/microsoft/wml-2018.xsd | 14 - .../office/schemas/microsoft/wml-cex-2018.xsd | 20 - .../office/schemas/microsoft/wml-cid-2016.xsd | 13 - .../microsoft/wml-sdtdatahash-2020.xsd | 4 - .../schemas/microsoft/wml-symex-2015.xsd | 8 - .../xlsx/scripts/office/soffice.py | 192 - .../xlsx/scripts/office/validate.py | 173 - .../scripts/office/validators/__init__.py | 15 - .../xlsx/scripts/office/validators/base.py | 875 ---- .../xlsx/scripts/office/validators/docx.py | 466 -- .../xlsx/scripts/office/validators/pptx.py | 441 -- .../scripts/office/validators/redlining.py | 299 -- .../builtin_skills/xlsx/scripts/recalc.py | 308 -- .../tools/implementations/skill_tool.rs | 5 +- .../tools/implementations/skills/builtin.rs | 83 +- .../tools/implementations/skills/catalog.rs | 7 +- .../tools/implementations/skills/policy.rs | 24 +- .../tools/implementations/skills/resolver.rs | 20 +- .../config/mode_config_canonicalizer.rs | 24 +- .../core/tests/office_archive_safety.py | 117 - .../agent-runtime/src/skills/catalog.rs | 16 - .../skill_contracts.rs | 51 +- src/web-ui/src/locales/en-US/flow-chat.json | 6 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 6 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 6 +- 199 files changed, 123 insertions(+), 73969 deletions(-) delete mode 100644 src/crates/assembly/core/builtin_skills/docx/LICENSE.txt delete mode 100644 src/crates/assembly/core/builtin_skills/docx/SKILL.md delete mode 100755 src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py delete mode 100755 src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py delete mode 100755 src/crates/assembly/core/builtin_skills/docx/scripts/comment.py delete mode 100755 src/crates/assembly/core/builtin_skills/docx/scripts/merge_runs.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_chart.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_slide.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_theme.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py delete mode 100755 src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml delete mode 100644 src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/SKILL.md delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/forms.md delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/reference.md delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py delete mode 100755 src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py delete mode 100644 src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/SKILL.md delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/__init__.py delete mode 100755 src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py delete mode 100755 src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_chart.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_slide.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_theme.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py delete mode 100755 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py delete mode 100644 src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py delete mode 100755 src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py create mode 100644 src/crates/assembly/core/builtin_skills/writing-skills/LICENSE.txt delete mode 100644 src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/SKILL.md delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_chart.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_slide.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_theme.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py delete mode 100755 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py delete mode 100644 src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py delete mode 100755 src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py delete mode 100644 src/crates/assembly/core/tests/office_archive_safety.py diff --git a/README.md b/README.md index 16a98a313e..7d3564a344 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Writes code, produces documents, and drives the desktop — with Mini Apps, a Ru | **Agentic Mini Apps** | A task gets its own interface — chart, board, form, panel — with a conversation bound to that interface's live state | | **Self-hosted multi-device control** | Login, cross-device session sync, and controlling one device from another run through a relay you deploy. Zero-knowledge; no vendor cloud in the path | | **Coding** | Plan, edit, test, and commit inside real Git repositories. Agentic, Plan, Debug, Deep Review, long-horizon tasks | -| **Office work** | Research, writing, PPT, DOCX, XLSX, PDF, meeting notes, reports | +| **Office work** | Research, writing, presentations, meeting notes, reports | | **Desktop execution** | Browser, terminal, desktop applications, the filesystem, and remote workspaces | | **Four tiers of customization** | Custom Agents → MCP / Skills / Hooks → Mini Apps → source-level changes | | **Performance** | 98.67% average KV cache hit rate; flashgrep searches Chromium-scale trees ~36x faster | @@ -91,7 +91,7 @@ Two kinds of complex work: shipping code in real repositories, and turning sourc | Scenario | Delivery goal | Typical capabilities | | --- | --- | --- | | **Coding** | Move from a real repository to a mergeable result. | Agentic, Plan, Debug, testing, Git, Deep Review, long-horizon tasks, and benchmarks. | -| **Office Work** | Move from source material to deliverable documents. | Research, PPT, DOCX, XLSX, PDF, summarization, writing, meeting notes, and reports. | +| **Office Work** | Move from source material to useful written and visual deliverables. | Research, presentations, summarization, writing, meeting notes, and reports. | **Shared capabilities** diff --git a/README.zh-CN.md b/README.zh-CN.md index 27386405f4..53a4cb7ec1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ | **Agentic Mini App** | 为任务生成专属界面——图表、看板、表单、面板——对话绑定该界面的实时状态 | | **自部署多设备互联互控** | 账号登录、跨设备会话同步、设备间操控,全部走你自己部署的 relay。零知识加密,不经第三方云 | | **编码交付** | 在真实 Git 仓库里规划、改代码、跑测试、提交。Agentic、Plan、Debug、Deep Review、长程任务 | -| **办公交付** | 调研、写作、PPT、DOCX、XLSX、PDF、会议纪要、报告 | +| **办公交付** | 调研、写作、演示文稿、会议纪要、报告 | | **桌面执行层** | 浏览器、终端、桌面软件、文件系统、远程工作区 | | **四层可定制** | 自定义 Agent → MCP / Skills / Hooks → Mini App → 源码级改造 | | **性能** | KV Cache 平均命中率 98.67%;flashgrep 在千万行仓库上搜索平均快约 36 倍 | @@ -91,7 +91,7 @@ pnpm run desktop:dev | 场景 | 目标交付 | 典型能力 | | --- | --- | --- | | **编码** | 从真实仓库推进到可合并结果。 | Agentic、Plan、Debug、测试、Git、Deep Review、长程任务、Benchmark。 | -| **办公** | 从资料推进到可交付文档。 | Research、PPT、DOCX、XLSX、PDF、总结、写作、会议纪要、报告。 | +| **办公** | 从资料推进到实用的文字和视觉交付物。 | 调研、演示文稿、总结、写作、会议纪要、报告。 | **通用能力** diff --git a/src/crates/assembly/core/builtin-skills-upstreams.json b/src/crates/assembly/core/builtin-skills-upstreams.json index d15e4a66f6..631b961269 100644 --- a/src/crates/assembly/core/builtin-skills-upstreams.json +++ b/src/crates/assembly/core/builtin-skills-upstreams.json @@ -2,18 +2,6 @@ "schema_version": 1, "synced_on": "2026-07-22", "sources": [ - { - "skills": ["docx", "pptx", "xlsx"], - "repository": "https://github.com/anthropics/skills", - "revision": "fa0fa64bdc967915dc8399e803be67759e1e62b8", - "local_patches": [ - "normalize upstream trailing whitespace", - "route documented archive editing through safe_extract and rezip", - "bound archive extraction and reject normalized-path collisions", - "use BitFun as the default Word comment author", - "retain the BitFun 2026 year-format example" - ] - }, { "skills": ["agent-browser"], "repository": "https://github.com/vercel-labs/agent-browser", diff --git a/src/crates/assembly/core/builtin_skills/docx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/docx/LICENSE.txt deleted file mode 100644 index c55ab42224..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -© 2025 Anthropic, PBC. All rights reserved. - -LICENSE: Use of these materials (including all code, prompts, assets, files, -and other components of this Skill) is governed by your agreement with -Anthropic regarding use of Anthropic's services. If no separate agreement -exists, use is governed by Anthropic's Consumer Terms of Service or -Commercial Terms of Service, as applicable: -https://www.anthropic.com/legal/consumer-terms -https://www.anthropic.com/legal/commercial-terms -Your applicable agreement is referred to as the "Agreement." "Services" are -as defined in the Agreement. - -ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the -contrary, users may not: - -- Extract these materials from the Services or retain copies of these - materials outside the Services -- Reproduce or copy these materials, except for temporary copies created - automatically during authorized use of the Services -- Create derivative works based on these materials -- Distribute, sublicense, or transfer these materials to any third party -- Make, offer to sell, sell, or import any inventions embodied in these - materials -- Reverse engineer, decompile, or disassemble these materials - -The receipt, viewing, or possession of these materials does not convey or -imply any license or right beyond those expressly granted above. - -Anthropic retains all right, title, and interest in these materials, -including all copyrights, patents, and other intellectual property rights. diff --git a/src/crates/assembly/core/builtin_skills/docx/SKILL.md b/src/crates/assembly/core/builtin_skills/docx/SKILL.md deleted file mode 100644 index ab3cbd09a5..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/SKILL.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: docx -description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files) or Word templates (.dotx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', '.dotx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx or .dotx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation." -license: Proprietary. LICENSE.txt has complete terms ---- - -# DOCX creation, editing, and analysis - -A `.docx` is a ZIP archive of XML files. Choose your approach by task: - -| Task | Approach | -|---|---| -| **Create** a new document | Write a `docx` (npm) script — see gotchas below | -| **Edit** an existing document | `safe_extract` → edit `word/document.xml` → `rezip` (docx-js cannot open existing files) | -| **Read** content | `pandoc -t markdown file.docx` | - -> Script paths below are relative to this skill's directory. - -## Creating with docx-js — gotchas - -`docx` is preinstalled — do not run `npm install` first; write the script and `require('docx')` directly. Only if that require fails: `npm install docx`. The model knows the API; these are the footguns: - -- **Page size defaults to A4.** For US Letter set `page: { size: { width: 12240, height: 15840 } }` (DXA; 1440 = 1″). -- **Landscape:** pass portrait dimensions and `orientation: PageOrientation.LANDSCAPE` — docx-js swaps width/height internally. -- **Tables need dual widths:** set `columnWidths` on the table AND `width` on every cell, both in `WidthType.DXA` (PERCENTAGE breaks in Google Docs). Column widths must sum to the table width. -- **Table shading:** use `ShadingType.CLEAR`, never `SOLID` (renders black). -- **Lists:** never insert `•` literally; use a `numbering` config with `LevelFormat.BULLET`. -- **`ImageRun` requires `type:`** (`"png"`, `"jpg"`, …). -- **`PageBreak` must be inside a `Paragraph`.** -- **Never use `\n`** — use separate `Paragraph` elements. -- **TOC:** headings must use built-in `HeadingLevel.*`; custom heading styles need `outlineLevel` set or they won't appear. -- **Don't use a table as a horizontal rule** — use a paragraph bottom border instead. -- **Dot-leader / right-aligned-on-same-line:** use `PositionalTab` (`alignment: PositionalTabAlignment.RIGHT`, `leader: PositionalTabLeader.DOT`) inside a `TextRun`, not literal `.` or space padding. - -## Verify the output - -After writing a `.docx`, render it and look at it: - -```bash -python scripts/office/soffice.py --headless --convert-to pdf output.docx -pdftoppm -jpeg -r 100 output.pdf page -ls page-*.jpg # then Read the images -``` - -`pdftoppm` zero-pads page numbers to the width of the page count (`page-01.jpg`…`page-12.jpg`). - -## Editing existing documents - -Legacy `.doc` files must be converted first: `python scripts/office/soffice.py --headless --convert-to docx file.doc`. - -```bash -python -c "import sys,zipfile; from pathlib import Path; from scripts.office.helpers import safe_extract; zf=zipfile.ZipFile(sys.argv[1]); safe_extract(zf, Path(sys.argv[2])); zf.close()" doc.docx unpacked -python scripts/merge_runs.py unpacked/ # coalesce fragmented runs so text is findable -# edit unpacked/word/document.xml in place — do NOT reformat or pretty-print -python -c "from pathlib import Path; from scripts.office.helpers import rezip; rezip(Path('unpacked'), Path('out.docx'))" -python scripts/office/validate.py out.docx --original doc.docx # XSD checks; --auto-repair fixes common issues -# redlining? add --author "" to check every edit is tracked -``` - -Word splits text across many `` runs (revision ids, spell-check markers), so a phrase you can see in the document often doesn't exist as a contiguous string in the XML. `merge_runs.py` merges adjacent identically-formatted runs in `word/document.xml` without changing content or rendering; it also accepts a `.docx` directly (`python scripts/merge_runs.py doc.docx -o merged.docx`). - -Use "BitFun" as the author for tracked changes and comments unless the user explicitly requests a different name. - -**Tracked changes:** when redlining, validate with `--author ""` (needs `--original`) — it reports any text you changed without a ``/`` around it, which is easy to do by accident and invisible in the accepted view. Wrap runs in ``/`` with `w:id`, `w:author`, `w:date` attributes. Inside ``, the text element is ``, not ``. A deleted paragraph mark (``) means "merge this paragraph into the next" — so deleting a paragraph outright is that plus a `` around every run. The `` must come before the rPr's other children; their order is schema-enforced. - -To produce a clean copy with all tracked changes accepted: `python scripts/accept_changes.py in.docx out.docx`. - -Accepting a deleted paragraph mark should join that paragraph to the one below it, so a paragraph whose runs are *all* deleted vanishes. Word does this; `accept_changes.py` and `pandoc --track-changes=accept` don't always. Both fail the same way — they strip the deleted text but leave the emptied paragraph behind, which reads as a stray empty bullet when it was auto-numbered: - -- `pandoc --track-changes=accept` never joins the paragraphs. -- `accept_changes.py` (LibreOffice) joins them correctly, except when the deleted paragraph is followed by an empty spacer paragraph. - -An empty bullet in either view is an artifact of that view, not a defect in the document. Check paragraph deletions in the XML. - -## Comments - -Comments require six cross-linked files. Use the helper — directory mode when you'll also be editing `document.xml` (saves an unzip/rezip cycle), `.docx`-direct mode otherwise: - -```bash -# Against an already-unpacked directory (preferred when also placing markers) -python scripts/comment.py unpacked/ "Fees & expenses cap is too low" -python scripts/comment.py unpacked/ "Agreed" --parent 0 - -# Against a .docx directly -python scripts/comment.py contract.docx "This cap is too low" -o annotated.docx -``` - -The script writes `comments.xml`, `commentsExtended.xml`, `commentsIds.xml`, `commentsExtensible.xml`, the relationships, and the content-type overrides. Comment IDs are auto-assigned. It then prints the ``/``/`` snippet to add to `word/document.xml` so the comment anchors to specific text — until you place those markers, the comment exists but is not visible. - -## Dependencies - -`docx` (npm, preinstalled — install only if `require('docx')` fails) · `pandoc` · LibreOffice (`soffice`) · `pdftoppm` (Poppler) diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py deleted file mode 100755 index 8b13789179..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py b/src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py deleted file mode 100755 index 8e36316191..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/accept_changes.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Accept all tracked changes in a DOCX file using LibreOffice. - -Requires LibreOffice (soffice) to be installed. -""" - -import argparse -import logging -import shutil -import subprocess -from pathlib import Path - -from office.soffice import get_soffice_env - -logger = logging.getLogger(__name__) - -LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile" -MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard" - -ACCEPT_CHANGES_MACRO = """ - - - Sub AcceptAllTrackedChanges() - Dim document As Object - Dim dispatcher As Object - - document = ThisComponent.CurrentController.Frame - dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") - - dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array()) - ThisComponent.store() - ThisComponent.close(True) - End Sub -""" - - -def accept_changes( - input_file: str, - output_file: str, -) -> tuple[None, str]: - input_path = Path(input_file) - output_path = Path(output_file) - - if not input_path.exists(): - return None, f"Error: Input file not found: {input_file}" - - if not input_path.suffix.lower() == ".docx": - return None, f"Error: Input file is not a DOCX file: {input_file}" - - try: - output_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(input_path, output_path) - except Exception as e: - return None, f"Error: Failed to copy input file to output location: {e}" - - if not _setup_libreoffice_macro(): - return None, "Error: Failed to setup LibreOffice macro" - - cmd = [ - "soffice", - "--headless", - f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", - "--norestore", - "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", - str(output_path.absolute()), - ] - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30, - check=False, - env=get_soffice_env(), - ) - except subprocess.TimeoutExpired: - return ( - None, - f"Successfully accepted all tracked changes: {input_file} -> {output_file}", - ) - - if result.returncode != 0: - return None, f"Error: LibreOffice failed: {result.stderr}" - - return ( - None, - f"Successfully accepted all tracked changes: {input_file} -> {output_file}", - ) - - -def _setup_libreoffice_macro() -> bool: - macro_dir = Path(MACRO_DIR) - macro_file = macro_dir / "Module1.xba" - - if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(): - return True - - if not macro_dir.exists(): - subprocess.run( - [ - "soffice", - "--headless", - f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", - "--terminate_after_init", - ], - capture_output=True, - timeout=10, - check=False, - env=get_soffice_env(), - ) - macro_dir.mkdir(parents=True, exist_ok=True) - - try: - macro_file.write_text(ACCEPT_CHANGES_MACRO) - return True - except Exception as e: - logger.warning(f"Failed to setup LibreOffice macro: {e}") - return False - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Accept all tracked changes in a DOCX file" - ) - parser.add_argument("input_file", help="Input DOCX file with tracked changes") - parser.add_argument( - "output_file", help="Output DOCX file (clean, no tracked changes)" - ) - args = parser.parse_args() - - _, message = accept_changes(args.input_file, args.output_file) - print(message) - - if "Error" in message: - raise SystemExit(1) diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/comment.py b/src/crates/assembly/core/builtin_skills/docx/scripts/comment.py deleted file mode 100755 index 7e16100192..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/comment.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Add comments to a DOCX document. - -Accepts either an unpacked directory OR a .docx/.dotx file directly. - -Usage: - # Against an unpacked directory (writes satellite files in place) - python comment.py unpacked/ "Comment text" - python comment.py unpacked/ "Reply text" --parent 0 - - # Against a .docx directly (extracts, writes satellite files, rezips) - python comment.py contract.docx "This cap is too low" -o annotated.docx - python comment.py contract.docx "Comment" --id 5 # explicit ID - -The comment ID is auto-assigned (max existing + 1) unless --id is given. -Plain text is XML-escaped automatically; if you pass already-escaped text -(e.g. &, ’) use --raw to skip escaping. - -After running, add markers to word/document.xml so the comment is visible: - - ... commented content ... - - -""" - -import argparse -import random -import shutil -import sys -import tempfile -import zipfile -from datetime import datetime, timezone -from pathlib import Path - -import defusedxml.minidom -from xml.parsers.expat import ExpatError -from xml.sax.saxutils import escape as xml_escape - -from office.helpers import opc_target, rezip as _rezip, safe_extract as _safe_extract - -TEMPLATE_DIR = Path(__file__).parent / "templates" -NS = { - "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "w14": "http://schemas.microsoft.com/office/word/2010/wordml", - "w15": "http://schemas.microsoft.com/office/word/2012/wordml", - "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", - "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", -} - -COMMENT_XML = """\ - - - - - - - - - - - - - {text} - - -""" - -COMMENT_MARKER_TEMPLATE = """ -Add to word/document.xml (markers must be direct children of w:p, never inside w:r): - - ... - - """ - -REPLY_MARKER_TEMPLATE = """ -Nest markers inside parent {pid}'s markers (direct children of w:p, never inside w:r): - - ... - - - """ - -SMART_QUOTE_ENTITIES = { - "“": "“", - "”": "”", - "‘": "‘", - "’": "’", -} - - -def _generate_hex_id() -> str: - return f"{random.randint(0, 0x7FFFFFFE):08X}" - - -def _encode_smart_quotes(text: str) -> str: - for char, entity in SMART_QUOTE_ENTITIES.items(): - text = text.replace(char, entity) - return text - - -def _append_xml(xml_path: Path, root_tag: str, content: str) -> None: - dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8")) - root = dom.getElementsByTagName(root_tag)[0] - ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items()) - wrapper_dom = defusedxml.minidom.parseString(f"{content}") - for child in wrapper_dom.documentElement.childNodes: - if child.nodeType == child.ELEMENT_NODE: - root.appendChild(dom.importNode(child, True)) - output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8")) - xml_path.write_text(output, encoding="utf-8") - - -def _find_para_id(comments_path: Path, comment_id: int) -> str | None: - dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) - for c in dom.getElementsByTagName("w:comment"): - if c.getAttribute("w:id") == str(comment_id): - for p in c.getElementsByTagName("w:p"): - if pid := p.getAttribute("w14:paraId"): - return pid - return None - - -def _next_comment_id(comments_path: Path) -> int: - if not comments_path.exists(): - return 0 - dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) - ids = [] - for c in dom.getElementsByTagName("w:comment"): - try: - ids.append(int(c.getAttribute("w:id"))) - except ValueError: - pass - return (max(ids) + 1) if ids else 0 - - -def _get_next_rid(rels_path: Path) -> int: - dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) - max_rid = 0 - for rel in dom.getElementsByTagName("Relationship"): - rid = rel.getAttribute("Id") - if rid and rid.startswith("rId"): - try: - max_rid = max(max_rid, int(rid[3:])) - except ValueError: - pass - return max_rid + 1 - - -def _has_relationship(rels_path: Path, target: str) -> bool: - dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) - return any( - rel.getAttribute("Target") == target - for rel in dom.getElementsByTagName("Relationship") - ) - - -def _has_content_type(ct_path: Path, part_name: str) -> bool: - dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) - return any( - o.getAttribute("PartName") == part_name - for o in dom.getElementsByTagName("Override") - ) - - -_COMMENT_RELS = [ - ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", "comments.xml"), - ("http://schemas.microsoft.com/office/2011/relationships/commentsExtended", "commentsExtended.xml"), - ("http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", "commentsIds.xml"), - ("http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", "commentsExtensible.xml"), -] -_COMMENT_OVERRIDES = [ - ("/word/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"), - ("/word/commentsExtended.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"), - ("/word/commentsIds.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml"), - ("/word/commentsExtensible.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml"), -] - - -def _ensure_comment_relationships(unpacked_dir: Path) -> None: - rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels" - if not rels_path.exists(): - return - dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) - root = dom.documentElement - comment_types = {rel_type for rel_type, _ in _COMMENT_RELS} - existing = set() - for rel in dom.getElementsByTagName("Relationship"): - if rel.getAttribute("Type") not in comment_types: - continue - part = opc_target( - rel.getAttribute("Target"), - "word/document.xml", - rel.getAttribute("TargetMode"), - ) - if part is not None: - existing.add(part) - next_rid = _get_next_rid(rels_path) - changed = False - for rel_type, target in _COMMENT_RELS: - if opc_target(target, "word/document.xml") in existing: - continue - rel = dom.createElement("Relationship") - rel.setAttribute("Id", f"rId{next_rid}") - rel.setAttribute("Type", rel_type) - rel.setAttribute("Target", target) - root.appendChild(rel) - next_rid += 1 - changed = True - if changed: - rels_path.write_bytes(dom.toxml(encoding="UTF-8")) - - -def _ensure_comment_content_types(unpacked_dir: Path) -> None: - ct_path = unpacked_dir / "[Content_Types].xml" - if not ct_path.exists(): - return - dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) - root = dom.documentElement - existing = { - o.getAttribute("PartName") - for o in dom.getElementsByTagName("Override") - } - changed = False - for part_name, content_type in _COMMENT_OVERRIDES: - if part_name in existing: - continue - override = dom.createElement("Override") - override.setAttribute("PartName", part_name) - override.setAttribute("ContentType", content_type) - root.appendChild(override) - changed = True - if changed: - ct_path.write_bytes(dom.toxml(encoding="UTF-8")) - - -def add_comment( - unpacked_dir: Path | str, - text: str, - comment_id: int | None = None, - author: str = "BitFun", - initials: str = "B", - parent_id: int | None = None, - raw: bool = False, -) -> tuple[int, str, str]: - unpacked_dir = Path(unpacked_dir) - if not raw: - text = xml_escape(text) - author = xml_escape(author, {'"': """}) - initials = xml_escape(initials, {'"': """}) - word = unpacked_dir / "word" - if not word.exists(): - raise FileNotFoundError(f"{word} not found (not an unpacked .docx?)") - - comments = word / "comments.xml" - if comment_id is None: - comment_id = _next_comment_id(comments) - - parent_para = None - if parent_id is not None: - parent_para = _find_para_id(comments, parent_id) if comments.exists() else None - if not parent_para: - raise ValueError(f"parent comment {parent_id} not found") - - para_id, durable_id = _generate_hex_id(), _generate_hex_id() - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - if not comments.exists(): - shutil.copy(TEMPLATE_DIR / "comments.xml", comments) - _ensure_comment_relationships(unpacked_dir) - _ensure_comment_content_types(unpacked_dir) - _append_xml( - comments, - "w:comments", - COMMENT_XML.format( - id=comment_id, author=author, date=ts, initials=initials, - para_id=para_id, text=text, - ), - ) - - ext = word / "commentsExtended.xml" - if not ext.exists(): - shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext) - if parent_para is not None: - _append_xml( - ext, "w15:commentsEx", - f'', - ) - else: - _append_xml( - ext, "w15:commentsEx", - f'', - ) - - ids = word / "commentsIds.xml" - if not ids.exists(): - shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids) - _append_xml( - ids, "w16cid:commentsIds", - f'', - ) - - extensible = word / "commentsExtensible.xml" - if not extensible.exists(): - shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible) - _append_xml( - extensible, "w16cex:commentsExtensible", - f'', - ) - - action = "reply" if parent_id is not None else "comment" - return comment_id, para_id, f"Added {action} id={comment_id} (paraId={para_id})" - - -def main() -> None: - p = argparse.ArgumentParser(description="Add a comment to a DOCX (directory or .docx file).") - p.add_argument("input", help="Unpacked DOCX directory OR a .docx/.dotx file") - p.add_argument("text", help="Comment text (plain text; XML-escaped automatically)") - p.add_argument("--raw", action="store_true", - help="Treat text as pre-escaped XML (skip automatic escaping)") - p.add_argument("--id", type=int, dest="comment_id", - help="Comment ID (default: auto-assign as max existing + 1)") - p.add_argument("--author", default="BitFun", help="Author name") - p.add_argument("--initials", default="B", help="Author initials") - p.add_argument("--parent", type=int, help="Parent comment ID (makes this a reply)") - p.add_argument("-o", "--output", - help="Output .docx path (only used when input is a .docx; default: overwrite input)") - args = p.parse_args() - - src = Path(args.input) - - try: - if src.is_dir(): - if args.output: - print("Warning: --output ignored for directory input", file=sys.stderr) - cid, _, msg = add_comment( - src, args.text, comment_id=args.comment_id, - author=args.author, initials=args.initials, - parent_id=args.parent, raw=args.raw, - ) - print(msg) - elif src.is_file() and src.suffix.lower() in (".docx", ".dotx"): - out = Path(args.output) if args.output else src - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - with zipfile.ZipFile(src) as zf: - _safe_extract(zf, tmp_path) - cid, _, msg = add_comment( - tmp_path, args.text, comment_id=args.comment_id, - author=args.author, initials=args.initials, - parent_id=args.parent, raw=args.raw, - ) - _rezip(tmp_path, out) - print(msg) - print(f"Wrote {out} (comment defined; add markers to word/document.xml to make it visible)") - else: - print(f"Error: {src} is neither a directory nor a .docx/.dotx file", file=sys.stderr) - sys.exit(1) - except (FileNotFoundError, ValueError, zipfile.BadZipFile, ExpatError) as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - if args.parent is not None: - print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid)) - else: - print(COMMENT_MARKER_TEMPLATE.format(cid=cid)) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/merge_runs.py b/src/crates/assembly/core/builtin_skills/docx/scripts/merge_runs.py deleted file mode 100755 index 977822929c..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/merge_runs.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Merge adjacent identically-formatted runs in a DOCX. - -Word fragments paragraph text across many elements (revision ids, -spell-check markers, editing history), which makes find-and-replace on -word/document.xml unreliable — the string you're looking for is split -across runs. This coalesces adjacent runs whose formatting () is -identical, strips rsid attributes and proofErr markers, and consolidates the -text elements — , and for text inside a tracked deletion. - -Rendering is unchanged. The text you search is what Word draws, which is not -always the bytes in the file: an element without xml:space="preserve" has its -edge whitespace trimmed before it reaches the page, so `Hello ` -followed by `world` reads "Helloworld" and merges to exactly that. - -Runs in two different / wrappers are never merged: that would -rewrite tracked-change structure, collapsing separate revisions into one. - -Only word/document.xml is processed (not headers, footers, or footnotes). - -Usage: - python merge_runs.py unpacked/ # after unzip, before editing - python merge_runs.py document.docx # rewrite in place - python merge_runs.py document.docx -o out.docx -""" - - -import argparse -import sys -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom - -from office.helpers import XML_SPACE, rendered_text, rezip, safe_extract - -WORDML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - -def merge_runs(input_dir: str) -> tuple[int, str]: - doc_xml = Path(input_dir) / "word" / "document.xml" - - if not doc_xml.exists(): - return 0, f"Error: {doc_xml} not found" - - try: - dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) - root = dom.documentElement - run_names = _run_tag_names(root) - - _remove_elements(root, "proofErr") - - runs = _find_runs(root, run_names) - _strip_rsid_attrs(runs) - - merge_count = 0 - for container in {run.parentNode for run in runs}: - merge_count += _merge_runs_in(container, run_names) - - doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) - return merge_count, f"Merged {merge_count} runs" - - except Exception as e: - return 0, f"Error: {e}" - - - - -def _is_element(node, tag: str) -> bool: - name = node.localName or node.tagName - return name == tag or name.endswith(f":{tag}") - - -def _run_tag_names(root) -> set[str]: - names = set() - for attr in root.attributes.values(): - if attr.value == WORDML_NS: - if attr.name == "xmlns": - names.add("r") - elif attr.name.startswith("xmlns:"): - names.add(attr.name.split(":", 1)[1] + ":r") - return names or {"w:r", "r"} - - -def _find_elements(root, tag: str) -> list: - results = [] - - def traverse(node): - if node.nodeType == node.ELEMENT_NODE: - if _is_element(node, tag): - results.append(node) - for child in node.childNodes: - traverse(child) - - traverse(root) - return results - - -def _find_runs(root, run_names: set[str]) -> list: - return [e for e in _find_elements(root, "r") if _is_run(e, run_names)] - - -def _get_child(parent, tag: str): - return next(iter(_get_children(parent, tag)), None) - - -def _get_children(parent, tag: str) -> list: - return [ - child - for child in parent.childNodes - if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) - ] - - -def _is_adjacent(elem1, elem2) -> bool: - node = elem1.nextSibling - while node: - if node == elem2: - return True - if node.nodeType == node.ELEMENT_NODE: - return False - if node.nodeType == node.TEXT_NODE and node.data.strip(XML_SPACE): - return False - node = node.nextSibling - return False - - - - -def _remove_elements(root, tag: str): - for elem in _find_elements(root, tag): - if elem.parentNode: - elem.parentNode.removeChild(elem) - - -def _strip_rsid_attrs(runs: list): - for run in runs: - for attr in list(run.attributes.values()): - if "rsid" in attr.name.lower(): - run.removeAttribute(attr.name) - - - - -def _merge_runs_in(container, run_names: set[str]) -> int: - merge_count = 0 - run = _first_child_run(container, run_names) - - while run: - while True: - next_elem = _next_element_sibling(run) - if next_elem and _is_run(next_elem, run_names) and _can_merge(run, next_elem): - _merge_run_content(run, next_elem) - container.removeChild(next_elem) - merge_count += 1 - else: - break - - _consolidate_text(run) - run = _next_sibling_run(run, run_names) - - return merge_count - - -def _first_child_run(container, run_names: set[str]): - for child in container.childNodes: - if child.nodeType == child.ELEMENT_NODE and _is_run(child, run_names): - return child - return None - - -def _next_element_sibling(node): - sibling = node.nextSibling - while sibling: - if sibling.nodeType == sibling.ELEMENT_NODE: - return sibling - sibling = sibling.nextSibling - return None - - -def _next_sibling_run(node, run_names: set[str]): - sibling = node.nextSibling - while sibling: - if sibling.nodeType == sibling.ELEMENT_NODE: - if _is_run(sibling, run_names): - return sibling - sibling = sibling.nextSibling - return None - - -def _is_run(node, run_names: set[str]) -> bool: - return node.tagName in run_names - - -def _can_merge(run1, run2) -> bool: - rpr1 = _get_child(run1, "rPr") - rpr2 = _get_child(run2, "rPr") - - if (rpr1 is None) != (rpr2 is None): - return False - if rpr1 is None: - return True - return rpr1.toxml() == rpr2.toxml() - - -def _merge_run_content(target, source): - for child in list(source.childNodes): - if child.nodeType == child.ELEMENT_NODE: - name = child.localName or child.tagName - if name != "rPr" and not name.endswith(":rPr"): - target.appendChild(child) - - -def _element_text(elem) -> str: - return "".join( - child.data - for child in elem.childNodes - if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) - ) - - -def _has_preserve(elem) -> bool: - return elem.getAttribute("xml:space") == "preserve" - - -def _rendered_text(elem) -> str: - return rendered_text(_element_text(elem), _has_preserve(elem)) - - -def _consolidate_text(run): - for tag in ("t", "delText"): - _consolidate_text_elements(run, tag) - - -def _consolidate_text_elements(run, tag: str): - t_elements = _get_children(run, tag) - - for i in range(len(t_elements) - 1, 0, -1): - curr, prev = t_elements[i], t_elements[i - 1] - - if _is_adjacent(prev, curr): - merged = _rendered_text(prev) + _rendered_text(curr) - had_preserve = _has_preserve(prev) or _has_preserve(curr) - - new_text = run.ownerDocument.createTextNode(merged) - for node in list(prev.childNodes): - if node.nodeType in (node.TEXT_NODE, node.CDATA_SECTION_NODE): - prev.removeChild(node) - else: - run.insertBefore(node, curr) - prev.appendChild(new_text) - for node in list(curr.childNodes): - if node.nodeType not in (node.TEXT_NODE, node.CDATA_SECTION_NODE): - run.insertBefore(node, curr) - - if merged != merged.strip(XML_SPACE) or had_preserve: - prev.setAttribute("xml:space", "preserve") - elif prev.hasAttribute("xml:space"): - prev.removeAttribute("xml:space") - - run.removeChild(curr) - - - - -def _merge_or_die(path: Path) -> str: - _, msg = merge_runs(str(path)) - if msg.startswith("Error"): - print(msg, file=sys.stderr) - sys.exit(1) - return msg - - -def main() -> None: - p = argparse.ArgumentParser( - description="Merge adjacent identically-formatted runs in a DOCX (directory or .docx file)." - ) - p.add_argument("input", help="Unpacked DOCX directory OR a .docx/.dotx file") - p.add_argument( - "-o", "--output", - help="Output .docx path (only valid when input is a .docx; default: overwrite input)", - ) - args = p.parse_args() - - src = Path(args.input) - - try: - if src.is_dir(): - if args.output: - p.error("--output is only valid for .docx input; directory input is modified in place") - print(_merge_or_die(src)) - elif src.is_file() and src.suffix.lower() in (".docx", ".dotx"): - out = Path(args.output) if args.output else src - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - with zipfile.ZipFile(src) as zf: - safe_extract(zf, tmp_path) - msg = _merge_or_die(tmp_path) - rezip(tmp_path, out) - print(f"{msg}; wrote {out}") - else: - print(f"Error: {src} is neither a directory nor a .docx/.dotx file", file=sys.stderr) - sys.exit(1) - except (OSError, ValueError, zipfile.BadZipFile) as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py deleted file mode 100644 index 188b00aff4..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/__init__.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import posixpath -import re -import stat -import tempfile -import urllib.parse -import zipfile -from pathlib import Path - -OOXML_FAMILY = { - ".docx": "docx", - ".dotx": "docx", - ".pptx": "pptx", - ".potx": "pptx", - ".xlsx": "xlsx", - ".xltx": "xlsx", -} - -_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") - -SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" - -MAX_ARCHIVE_MEMBERS = 10_000 -MAX_ARCHIVE_MEMBER_SIZE = 1 * 1024 * 1024 * 1024 -MAX_ARCHIVE_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 -MAX_ARCHIVE_COMPRESSION_RATIO = 1_000 - - -def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: - if not target: - return None - if target_mode.lower() == "external": - return None - if _SCHEME_RE.match(target): - return None - - target = urllib.parse.unquote(target) - - if "\\" in target: - raise ValueError(f"relationship target is not a POSIX part name: {target!r}") - - if target.startswith("/"): - joined = target.lstrip("/") - else: - joined = posixpath.join(posixpath.dirname(source_part), target) - - parts: list[str] = [] - for segment in posixpath.normpath(joined).split("/"): - if segment in ("", "."): - continue - if segment == "..": - if not parts: - raise ValueError(f"relationship target escapes the package: {target!r}") - parts.pop() - else: - parts.append(segment) - - if not parts: - raise ValueError(f"relationship target resolves to nothing: {target!r}") - return "/".join(parts) - - -def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: - owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) - return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") - - -def part_text(data: bytes) -> str: - return data.decode("utf-8", "surrogateescape") - - -XML_SPACE = " \t\r\n" - - -def rendered_text(text: str, preserve: bool) -> str: - return text if preserve else text.strip(XML_SPACE) - - -def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: - dest = dest.resolve() - members = zf.infolist() - if len(members) > MAX_ARCHIVE_MEMBERS: - raise ValueError(f"archive has too many entries: {len(members)}") - - total_size = 0 - targets: set[str] = set() - file_targets: set[str] = set() - validated: list[tuple[zipfile.ZipInfo, Path]] = [] - for m in members: - if stat.S_ISLNK(m.external_attr >> 16): - raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") - target = (dest / m.filename).resolve() - if target == dest or not target.is_relative_to(dest): - raise ValueError(f"unsafe archive entry: {m.filename!r}") - target_key = os.path.normcase(str(target)) - if target_key in targets: - raise ValueError(f"duplicate archive entry: {m.filename!r}") - targets.add(target_key) - if not m.is_dir(): - file_targets.add(target_key) - validated.append((m, target)) - if m.file_size > MAX_ARCHIVE_MEMBER_SIZE: - raise ValueError(f"archive entry is too large: {m.filename!r}") - total_size += m.file_size - if total_size > MAX_ARCHIVE_TOTAL_SIZE: - raise ValueError("archive expands beyond the allowed total size") - if m.file_size and ( - m.compress_size == 0 - or m.file_size > m.compress_size * MAX_ARCHIVE_COMPRESSION_RATIO - ): - raise ValueError(f"archive entry has an unsafe compression ratio: {m.filename!r}") - - for m, target in validated: - for parent in target.parents: - if parent == dest: - break - if os.path.normcase(str(parent)) in file_targets: - raise ValueError(f"archive file entry conflicts with child path: {m.filename!r}") - - for m, _ in validated: - zf.extract(m, dest) - - -def rezip(src_dir: Path, out_path: Path) -> None: - files = sorted(p for p in src_dir.rglob("*") if p.is_file()) - ct = src_dir / "[Content_Types].xml" - fd, tmp_name = tempfile.mkstemp( - prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent - ) - tmp_out = Path(tmp_name) - try: - with os.fdopen(fd, "wb") as fh: - with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: - if ct.exists(): - zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) - for f in files: - if f == ct: - continue - zf.write(f, f.relative_to(src_dir)) - if out_path.exists(): - mode = out_path.stat().st_mode & 0o777 - else: - umask = os.umask(0) - os.umask(umask) - mode = 0o666 & ~umask - os.chmod(tmp_out, mode) - os.replace(tmp_out, out_path) - finally: - if tmp_out.exists(): - tmp_out.unlink() diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_chart.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_chart.py deleted file mode 100644 index 209cb7c58b..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_chart.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Find chart XML that PowerPoint refuses but the schema accepts. - -Detection only: for either fault more than one repair is valid, and only the -author knows which was meant. -""" - - -from __future__ import annotations - -import re -from typing import Mapping - -from . import part_text - - -_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") - -_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") -_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") - -def _strip_ext_lst(text: str) -> str: - out, cursor = [], 0 - for lo, hi in _ext_lst_spans(text): - out.append(text[cursor:lo]) - cursor = hi - out.append(text[cursor:]) - return "".join(out) - -_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) -ILLEGAL_ON_STACKED = frozenset({"outEnd"}) -LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") - - -def _check_stacked_label_positions(part: str, xml: str) -> list[str]: - problems: list[str] = [] - for match in _BAR_GROUP_RE.finditer(xml): - block = _strip_ext_lst(match.group(0)) - group = match.group(1) - - grouping = _GROUPING_RE.search(block) - if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: - continue - - bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] - for pos in sorted(set(bad)): - problems.append( - f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' - f"{grouping.group(1)} {group}; PowerPoint allows only " - f"{', '.join(LEGAL_ON_STACKED)} there" - ) - return problems - - - -_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -_AXID_RE = re.compile( - r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" -) - -_AXIS_DECL_RE = re.compile( - r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" -) - -AXID_LIMIT = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, - "surfaceChart": 3, "surface3DChart": 3, -} - -AXID_MINIMUM = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, - "line3DChart": 3, "surface3DChart": 3, -} - - -def _declared_axes(xml: str) -> dict[str, list[str]]: - axes: dict[str, list[str]] = {} - for kind, axid in _AXIS_DECL_RE.findall(xml): - axes.setdefault(kind, []).append(axid) - return axes - - -def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: - category = axes.get("catAx", []) + axes.get("dateAx", []) - value = axes.get("valAx", []) - series = axes.get("serAx", []) - if len(category) != 1 or len(value) != 1 or len(series) > 1: - return None - ids = [category[0], value[0]] - if limit >= 3 and series: - ids.append(series[0]) - return ids - - -def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: - if kind not in AXID_LIMIT: - return None - ids = _AXID_RE.findall(block) - declared = {i for group in axes.values() for i in group} - if len([i for i in ids if i in declared]) >= 2: - return None - return ids - - -def _check_chart_axis_references(part: str, xml: str) -> list[str]: - axes = _declared_axes(xml) - problems: list[str] = [] - declared = {i for group in axes.values() for i in group} - for match in _ANY_CHART_GROUP_RE.finditer(xml): - kind, block = match.group(1), match.group(0) - ids = _undeclared_axes(kind, block, axes) - if ids is None: - continue - if not ids: - problems.append( - f"{part}: declares no this part can resolve; a chart " - f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" - ) - continue - dead = [i for i in ids if i not in declared] - canonical = _canonical_ids(axes, AXID_LIMIT[kind]) - if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: - hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" - else: - hint = ("Fix: the part declares several axes of a kind -- declare the " - "secondary axes the series expects, or drop them") - detail = (f"of which {', '.join(dead)} name no declared axis" - if dead else f"only {len(ids)} of which this part declares") - problems.append( - f"{part}: references axId {', '.join(ids)}, {detail}, " - f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" - ) - return problems - - -def _ext_lst_spans(text: str) -> list[tuple[int, int]]: - spans: list[tuple[int, int]] = [] - depth = 0 - start = 0 - for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): - closing, self_closing = match.group(1), match.group(2) - if self_closing: - continue - if closing: - depth -= 1 - if depth == 0: - spans.append((start, match.end())) - else: - if depth == 0: - start = match.start() - depth += 1 - return spans - - -CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) - - -def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: - problems: list[str] = [] - for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): - xml = part_text(files[part]) - for check in CHART_CHECKS: - problems.extend(check(part, xml)) - return problems diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_slide.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_slide.py deleted file mode 100644 index 22f9aee0ff..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_slide.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Pick the slide-XML schema errors PowerPoint refuses the file over. - -A denylist over lxml's messages, so an unrecognised error class is a miss rather -than a false alarm. -""" - - -from __future__ import annotations - -import re - -SLIDE_PART_RE = re.compile( - r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" - r"/[^/]+\.xml" -) - -FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( - ( - re.compile(r"\}tableStyleId': This element is not expected"), - "two in one (the schema allows one)", - ), - ( - re.compile(r"\}srgbClr', attribute 'val'"), - "a colour that is not six hex digits", - ), - ( - re.compile(r"\}txBody': Missing child element"), - "a with no children", - ), - ( - re.compile(r"\}miter', attribute 'lim'"), - 'a line join with lim="NaN"', - ), - ( - re.compile(r"\}uLnTx': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}overrideClrMapping': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}nvGrpSpPr': Missing child element"), - "a with no children", - ), -) - - -def is_schema_verdict(error: str) -> bool: - return error.startswith("Element ") - - -def fatal_slide_errors(errors: set[str]) -> list[str]: - out = [] - for error in sorted(errors): - for pattern, meaning in FATAL_SLIDE_ERRORS: - if pattern.search(error): - out.append(f"{meaning}: {error}") - break - return out diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_theme.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_theme.py deleted file mode 100644 index 5ef4c3e835..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/helpers/pptx_theme.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Find masters sharing a theme part in the way PowerPoint refuses to open. - -Reports only; the fix is to move back to directly after - in ppt/presentation.xml. -""" - - -from __future__ import annotations - -import posixpath -import re -from typing import Mapping - -from . import part_text - -THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" - -_MASTER_RE = re.compile( - r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" - r"(?:slide|notes|handout)Master(?P\d+)\.xml$" -) -_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} - -_RELATIONSHIP_RE = re.compile( - r"]*?(?:/>|>.*?)", re.DOTALL -) - - -def _sort_key(name: str) -> tuple[int, int]: - m = _MASTER_RE.match(name) - assert m is not None - return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) - - -def _rels_path(part: str) -> str: - directory, base = posixpath.split(part) - return f"{directory}/_rels/{base}.rels" - - -def _resolve(rels_path: str, target: str) -> str: - if target.startswith("/"): - return target.lstrip("/") - part_dir = posixpath.dirname(posixpath.dirname(rels_path)) - return posixpath.normpath(posixpath.join(part_dir, target)) - - -def _theme_rel(files: Mapping[str, bytes], master: str): - rels_path = _rels_path(master) - rels = files.get(rels_path) - if rels is None: - return None - for element in _RELATIONSHIP_RE.findall(part_text(rels)): - if f'Type="{THEME_REL_TYPE}"' not in element: - continue - target = re.search(r'\bTarget="([^"]+)"', element) - if target is None: - continue - return rels_path, element, _resolve(rels_path, target.group(1)) - return None - - -def _masters(files: Mapping[str, bytes]) -> list[str]: - return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) - - -_PRESENTATION = "ppt/presentation.xml" -_NOTES_MASTERS = "ppt/notesMasters/" -_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) -_AFTER_SLDIDLST_RE = re.compile( - r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL -) - - -def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: - data = files.get(_PRESENTATION) - if data is None: - return False - match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) - return match is not None and match.group(1) == " bool: - return inert_notes and master.startswith(_NOTES_MASTERS) - - -def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - ] - - -def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - inert_notes = _notes_master_share_is_inert(files) - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - if not _is_inert(master, inert_notes) - ] diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd deleted file mode 100644 index 6454ef9a94..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +++ /dev/null @@ -1,1499 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd deleted file mode 100644 index afa4f463e3..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd deleted file mode 100644 index 64e66b8abd..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +++ /dev/null @@ -1,1085 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd deleted file mode 100644 index 687eea8297..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd deleted file mode 100644 index 6ac81b06b7..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +++ /dev/null @@ -1,3081 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd deleted file mode 100644 index 1dbf05140d..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd deleted file mode 100644 index f1af17db4e..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd deleted file mode 100644 index 0a185ab6ed..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd deleted file mode 100644 index 14ef488865..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +++ /dev/null @@ -1,1676 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd deleted file mode 100644 index c20f3bf147..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd deleted file mode 100644 index ac60252262..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd deleted file mode 100644 index 424b8ba8d1..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd deleted file mode 100644 index 2bddce2921..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd deleted file mode 100644 index 8a8c18ba2d..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd deleted file mode 100644 index 5c42706a0d..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd deleted file mode 100644 index 853c341c87..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd deleted file mode 100644 index da835ee82d..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd deleted file mode 100644 index 87ad2658fa..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +++ /dev/null @@ -1,582 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd deleted file mode 100644 index 9e86f1b2be..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd deleted file mode 100644 index d0be42e757..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +++ /dev/null @@ -1,4439 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd deleted file mode 100644 index 8821dd183c..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +++ /dev/null @@ -1,570 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd deleted file mode 100644 index ca2575c753..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +++ /dev/null @@ -1,509 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd deleted file mode 100644 index dd079e603f..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd deleted file mode 100644 index 3dd6cf625a..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd deleted file mode 100644 index f1041e34ef..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd deleted file mode 100644 index 9c5b7a6334..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +++ /dev/null @@ -1,3646 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd deleted file mode 100644 index 0f13678d80..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - See http://www.w3.org/XML/1998/namespace.html and - http://www.w3.org/TR/REC-xml for information about this namespace. - - This schema document describes the XML namespace, in a form - suitable for import by other schema documents. - - Note that local names in this namespace are intended to be defined - only by the World Wide Web Consortium or its subgroups. The - following names are currently defined in this namespace and should - not be used with conflicting semantics by any Working Group, - specification, or document instance: - - base (as an attribute name): denotes an attribute whose value - provides a URI to be used as the base for interpreting any - relative URIs in the scope of the element on which it - appears; its value is inherited. This name is reserved - by virtue of its definition in the XML Base specification. - - lang (as an attribute name): denotes an attribute whose value - is a language code for the natural language of the content of - any element; its value is inherited. This name is reserved - by virtue of its definition in the XML specification. - - space (as an attribute name): denotes an attribute whose - value is a keyword indicating what whitespace processing - discipline is intended for the content of the element; its - value is inherited. This name is reserved by virtue of its - definition in the XML specification. - - Father (in any context at all): denotes Jon Bosak, the chair of - the original XML Working Group. This name is reserved by - the following decision of the W3C XML Plenary and - XML Coordination groups: - - In appreciation for his vision, leadership and dedication - the W3C XML Plenary on this 10th day of February, 2000 - reserves for Jon Bosak in perpetuity the XML name - xml:Father - - - - - This schema defines attributes and an attribute group - suitable for use by - schemas wishing to allow xml:base, xml:lang or xml:space attributes - on elements they define. - - To enable this, such a schema must import this schema - for the XML namespace, e.g. as follows: - <schema . . .> - . . . - <import namespace="http://www.w3.org/XML/1998/namespace" - schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> - - Subsequently, qualified reference to any of the attributes - or the group defined below will have the desired effect, e.g. - - <type . . .> - . . . - <attributeGroup ref="xml:specialAttrs"/> - - will define a type which will schema-validate an instance - element with any of those attributes - - - - In keeping with the XML Schema WG's standard versioning - policy, this schema document will persist at - http://www.w3.org/2001/03/xml.xsd. - At the date of issue it can also be found at - http://www.w3.org/2001/xml.xsd. - The schema document at that URI may however change in the future, - in order to remain compatible with the latest version of XML Schema - itself. In other words, if the XML Schema namespace changes, the version - of this document at - http://www.w3.org/2001/xml.xsd will change - accordingly; the version at - http://www.w3.org/2001/03/xml.xsd will not change. - - - - - - In due course, we should install the relevant ISO 2- and 3-letter - codes as the enumerated possible values . . . - - - - - - - - - - - - - - - See http://www.w3.org/TR/xmlbase/ for - information about this attribute. - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd deleted file mode 100644 index a6de9d2733..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd deleted file mode 100644 index 10e978b661..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd deleted file mode 100644 index 4248bf7a39..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd deleted file mode 100644 index 5649746712..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd deleted file mode 100644 index ef725457cf..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd deleted file mode 100644 index f65f777730..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +++ /dev/null @@ -1,560 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd deleted file mode 100644 index 6b00755a9a..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd deleted file mode 100644 index f321d333a5..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd deleted file mode 100644 index 364c6a9b8d..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd deleted file mode 100644 index fed9d15b7f..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd deleted file mode 100644 index 680cf15400..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd deleted file mode 100644 index 89ada90837..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py deleted file mode 100644 index 0b4c99deca..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/soffice.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Helper for running LibreOffice (soffice) in environments where AF_UNIX -sockets may be blocked (e.g., sandboxed VMs). Detects the restriction -at runtime and applies an LD_PRELOAD shim if needed. - -Usage: - from office.soffice import run_soffice - - result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) - -Call soffice through run_soffice, not through subprocess with get_soffice_env(): -the env dict carries the shim but names no user profile, and a non-root sandbox -cannot bootstrap the default one -- soffice aborts with "User installation could -not be completed" and converts nothing. get_soffice_env() stays public for the -callers that build their own argv (they must pass -env:UserInstallation too). -""" - -import contextlib -import os -import socket -import subprocess -import tempfile -from collections.abc import Iterable -from pathlib import Path - - -def get_soffice_env() -> dict: - env = os.environ.copy() - env["SAL_USE_VCLPLUGIN"] = "svp" - - if _needs_shim(): - shim = _ensure_shim() - env["LD_PRELOAD"] = str(shim) - - return env - - -def run_soffice(args: Iterable[str], **kwargs) -> subprocess.CompletedProcess: - args = list(args) - with contextlib.ExitStack() as stack: - if not any(str(a).startswith("-env:UserInstallation") for a in args): - profile = stack.enter_context( - tempfile.TemporaryDirectory(prefix="lo_profile_", ignore_cleanup_errors=True) - ) - args = [f"-env:UserInstallation={Path(profile).as_uri()}"] + args - return subprocess.run(["soffice"] + args, env=get_soffice_env(), **kwargs) - - - -_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" - - -def _needs_shim() -> bool: - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.close() - return False - except OSError: - return True - - -def _ensure_shim() -> Path: - if _SHIM_SO.exists(): - return _SHIM_SO - - src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" - src.write_text(_SHIM_SOURCE) - subprocess.run( - ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], - check=True, - capture_output=True, - ) - src.unlink() - return _SHIM_SO - - - -_SHIM_SOURCE = r""" -#define _GNU_SOURCE -#include -#include -#include -#include -#include -#include -#include - -static int (*real_socket)(int, int, int); -static int (*real_socketpair)(int, int, int, int[2]); -static int (*real_listen)(int, int); -static int (*real_accept)(int, struct sockaddr *, socklen_t *); -static int (*real_close)(int); -static int (*real_read)(int, void *, size_t); - -/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ -static int is_shimmed[1024]; -static int peer_of[1024]; -static int wake_r[1024]; /* accept() blocks reading this */ -static int wake_w[1024]; /* close() writes to this */ -static int listener_fd = -1; /* FD that received listen() */ - -__attribute__((constructor)) -static void init(void) { - real_socket = dlsym(RTLD_NEXT, "socket"); - real_socketpair = dlsym(RTLD_NEXT, "socketpair"); - real_listen = dlsym(RTLD_NEXT, "listen"); - real_accept = dlsym(RTLD_NEXT, "accept"); - real_close = dlsym(RTLD_NEXT, "close"); - real_read = dlsym(RTLD_NEXT, "read"); - for (int i = 0; i < 1024; i++) { - peer_of[i] = -1; - wake_r[i] = -1; - wake_w[i] = -1; - } -} - -/* ---- socket ---------------------------------------------------------- */ -int socket(int domain, int type, int protocol) { - if (domain == AF_UNIX) { - int fd = real_socket(domain, type, protocol); - if (fd >= 0) return fd; - /* socket(AF_UNIX) blocked – fall back to socketpair(). */ - int sv[2]; - if (real_socketpair(domain, type, protocol, sv) == 0) { - if (sv[0] >= 0 && sv[0] < 1024) { - is_shimmed[sv[0]] = 1; - peer_of[sv[0]] = sv[1]; - int wp[2]; - if (pipe(wp) == 0) { - wake_r[sv[0]] = wp[0]; - wake_w[sv[0]] = wp[1]; - } - } - return sv[0]; - } - errno = EPERM; - return -1; - } - return real_socket(domain, type, protocol); -} - -/* ---- listen ---------------------------------------------------------- */ -int listen(int sockfd, int backlog) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - listener_fd = sockfd; - return 0; - } - return real_listen(sockfd, backlog); -} - -/* ---- accept ---------------------------------------------------------- */ -int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - /* Block until close() writes to the wake pipe. */ - if (wake_r[sockfd] >= 0) { - char buf; - real_read(wake_r[sockfd], &buf, 1); - } - errno = ECONNABORTED; - return -1; - } - return real_accept(sockfd, addr, addrlen); -} - -/* ---- close ----------------------------------------------------------- */ -int close(int fd) { - if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { - int was_listener = (fd == listener_fd); - is_shimmed[fd] = 0; - - if (wake_w[fd] >= 0) { /* unblock accept() */ - char c = 0; - write(wake_w[fd], &c, 1); - real_close(wake_w[fd]); - wake_w[fd] = -1; - } - if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } - if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } - - if (was_listener) - _exit(0); /* conversion done – exit */ - } - return real_close(fd); -} -""" - - - -if __name__ == "__main__": - import sys - result = run_soffice(sys.argv[1:]) - sys.exit(result.returncode) diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py deleted file mode 100755 index 8fbd2f71ca..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validate.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Command line tool to validate Office document XML files against XSD schemas and tracked changes. - -Usage: - python validate.py [--original ] [--auto-repair] [--author NAME] - -The first argument can be either: -- An unpacked directory containing the Office document XML files -- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory - -Auto-repair fixes: -- paraId/durableId values that exceed OOXML limits -- Missing xml:space="preserve" on w:t elements with whitespace -""" - -import argparse -import sys -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import OOXML_FAMILY, rezip, safe_extract -from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator - -WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - -def _fail(message: str): - print(f"Error: {message}", file=sys.stderr) - sys.exit(2) - - -def _has_tracked_changes(unpacked_dir: Path) -> bool: - document = unpacked_dir / "word" / "document.xml" - if not document.is_file(): - return False - try: - root = ET.parse(document).getroot() - except (ET.ParseError, DefusedXmlException): - return False - tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} - return any(elem.tag in tracked for elem in root.iter()) - - -def main(): - parser = argparse.ArgumentParser(description="Validate Office document XML files") - parser.add_argument( - "path", - help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", - ) - parser.add_argument( - "--original", - required=False, - default=None, - help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose output", - ) - parser.add_argument( - "--auto-repair", - action="store_true", - help="Automatically repair common issues (hex IDs, whitespace preservation). " - "Modifies the input in place: repairs to a packed file are written back to it.", - ) - parser.add_argument( - "--author", - default=None, - help="The name you are redlining under. Passing it turns on the " - "tracked-change check: any text differing from --original without a " - "/ recording it is reported. Untracked edits carry no " - "author, so the check covers them whoever made them — the name marks " - "the run as redlining work and is not used to filter. Requires " - "--original; docx only.", - ) - args = parser.parse_args() - - if args.author is not None and not args.original: - _fail("--author requires --original") - - path = Path(args.path) - if not path.exists(): - _fail(f"{path} does not exist") - - original_file = None - if args.original: - original_file = Path(args.original) - if not original_file.is_file(): - _fail(f"{original_file} is not a file") - if original_file.suffix.lower() not in OOXML_FAMILY: - _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") - - family = OOXML_FAMILY.get((original_file or path).suffix.lower()) - if family is None: - _fail( - f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." - ) - - if args.author is not None and family != "docx": - _fail(f"--author only applies to docx files, not {family}") - - packed_file = None - temp_dir_ctx = None - if path.is_file() and path.suffix.lower() in OOXML_FAMILY: - packed_file = path - temp_dir_ctx = tempfile.TemporaryDirectory() - unpacked_dir = Path(temp_dir_ctx.name) - try: - with zipfile.ZipFile(path, "r") as zf: - safe_extract(zf, unpacked_dir) - except (zipfile.BadZipFile, ValueError, OSError) as e: - _fail(f"cannot unpack {path}: {e}") - else: - if not path.is_dir(): - _fail(f"{path} is not a directory or Office file") - unpacked_dir = path - - match family: - case "docx": - validators = [ - DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - if args.author is not None: - validators.append( - RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) - ) - elif original_file and _has_tracked_changes(unpacked_dir): - print( - "Note: this document has tracked changes; they were not " - "checked against the original (pass --author to check)." - ) - case "pptx": - validators = [ - PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - case "xlsx": - exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") - print( - f"No XSD schema validation is performed for xlsx-family files ({exts}). " - "For formula-error checking, use scripts/recalc.py instead." - ) - sys.exit(0) - case _: - print(f"Error: Validation not supported for file type {family}") - sys.exit(1) - - if args.auto_repair: - total_repairs = sum(v.repair() for v in validators) - if total_repairs: - print(f"Auto-repaired {total_repairs} issue(s)") - if packed_file is not None: - rezip(unpacked_dir, packed_file) - print(f"Wrote repaired file to {packed_file}") - - success = all([v.validate() for v in validators]) - - if temp_dir_ctx is not None: - temp_dir_ctx.cleanup() - - if success: - print("All validations PASSED!") - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py deleted file mode 100644 index db092ece7e..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Validation modules for Word document processing. -""" - -from .base import BaseSchemaValidator -from .docx import DOCXSchemaValidator -from .pptx import PPTXSchemaValidator -from .redlining import RedliningValidator - -__all__ = [ - "BaseSchemaValidator", - "DOCXSchemaValidator", - "PPTXSchemaValidator", - "RedliningValidator", -] diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py deleted file mode 100644 index 19d52a7fe0..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/base.py +++ /dev/null @@ -1,875 +0,0 @@ -""" -Base validator with common validation logic for document files. -""" - -import re -from pathlib import Path - -import defusedxml.minidom -from functools import lru_cache - -import lxml.etree - -from helpers import safe_extract - - -@lru_cache(maxsize=None) -def _load_schema(schema_path: str): - with open(schema_path, "rb") as xsd_file: - xsd_doc = lxml.etree.parse( - xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path - ) - return lxml.etree.XMLSchema(xsd_doc) - -class BaseSchemaValidator: - - IGNORED_VALIDATION_ERRORS = [ - "hyphenationZone", - "purl.org/dc/terms", - ] - - UNIQUE_ID_REQUIREMENTS = { - "comment": ("id", "file"), - "commentrangestart": ("id", "file"), - "commentrangeend": ("id", "file"), - "bookmarkstart": ("id", "file"), - "bookmarkend": ("id", "file"), - "sldid": ("id", "file"), - "sldmasterid": ("id", "global"), - "sldlayoutid": ("id", "global"), - "cm": ("authorid", "file"), - "sheet": ("sheetid", "file"), - "definedname": ("id", "file"), - "cxnsp": ("id", "file"), - "sp": ("id", "file"), - "pic": ("id", "file"), - "grpsp": ("id", "file"), - } - - EXCLUDED_ID_CONTAINERS = { - "sectionlst", - } - - ELEMENT_RELATIONSHIP_TYPES = {} - - SCHEMA_MAPPINGS = { - "word": "ISO-IEC29500-4_2016/wml.xsd", - "ppt": "ISO-IEC29500-4_2016/pml.xsd", - "xl": "ISO-IEC29500-4_2016/sml.xsd", - "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", - "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", - "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", - "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", - ".rels": "ecma/fouth-edition/opc-relationships.xsd", - "people.xml": "microsoft/wml-2012.xsd", - "commentsIds.xml": "microsoft/wml-cid-2016.xsd", - "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", - "commentsExtended.xml": "microsoft/wml-2012.xsd", - "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", - "theme": "ISO-IEC29500-4_2016/dml-main.xsd", - "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", - } - - MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" - XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" - - PACKAGE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/relationships" - ) - OFFICE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - ) - CONTENT_TYPES_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/content-types" - ) - - MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} - - OOXML_NAMESPACES = { - "http://schemas.openxmlformats.org/officeDocument/2006/math", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships", - "http://schemas.openxmlformats.org/schemaLibrary/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/chart", - "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/diagram", - "http://schemas.openxmlformats.org/drawingml/2006/picture", - "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", - "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "http://schemas.openxmlformats.org/presentationml/2006/main", - "http://schemas.openxmlformats.org/spreadsheetml/2006/main", - "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", - "http://www.w3.org/XML/1998/namespace", - } - - def __init__(self, unpacked_dir, original_file=None, verbose=False): - self.unpacked_dir = Path(unpacked_dir).resolve() - self.original_file = Path(original_file) if original_file else None - self.verbose = verbose - - self.schemas_dir = Path(__file__).parent.parent / "schemas" - - patterns = ["*.xml", "*.rels"] - self.xml_files = [ - f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) - ] - - if not self.xml_files: - print(f"Warning: No XML files found in {self.unpacked_dir}") - - def validate(self): - raise NotImplementedError("Subclasses must implement the validate method") - - def repair(self) -> int: - return self.repair_whitespace_preservation() - - def repair_whitespace_preservation(self) -> int: - repairs = 0 - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - pending = [] - - for elem in dom.getElementsByTagName("*"): - local_name = elem.tagName.rsplit(":", 1)[-1] - if local_name in ("t", "delText", "instrText", "delInstrText"): - text = "".join( - child.data - for child in elem.childNodes - if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) - ) - ws = (" ", "\t", "\n", "\r") - if text and (text.startswith(ws) or text.endswith(ws)): - if elem.getAttribute("xml:space") != "preserve": - elem.setAttribute("xml:space", "preserve") - text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) - pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") - - if pending: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - def validate_xml(self): - errors = [] - - for xml_file in self.xml_files: - try: - lxml.etree.parse(str(xml_file)) - except lxml.etree.XMLSyntaxError as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {e.lineno}: {e.msg}" - ) - except Exception as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Unexpected error: {str(e)}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} XML violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All XML files are well-formed") - return True - - def validate_namespaces(self): - errors = [] - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - declared = set(root.nsmap.keys()) - {None} - - for attr_val in [ - v for k, v in root.attrib.items() if k.endswith("Ignorable") - ]: - undeclared = set(attr_val.split()) - declared - errors.extend( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Namespace '{ns}' in Ignorable but not declared" - for ns in undeclared - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} namespace issues:") - for error in errors: - print(error) - return False - if self.verbose: - print("PASSED - All namespace prefixes properly declared") - return True - - def validate_unique_ids(self): - errors = [] - global_ids = {} - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - file_ids = {} - - mc_elements = root.xpath( - ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} - ) - for elem in mc_elements: - elem.getparent().remove(elem) - - for elem in root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag = ( - elem.tag.split("}")[-1].lower() - if "}" in elem.tag - else elem.tag.lower() - ) - - if tag in self.UNIQUE_ID_REQUIREMENTS: - in_excluded_container = any( - ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS - for ancestor in elem.iterancestors() - ) - if in_excluded_container: - continue - - attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] - - id_value = None - for attr, value in elem.attrib.items(): - attr_local = ( - attr.split("}")[-1].lower() - if "}" in attr - else attr.lower() - ) - if attr_local == attr_name: - id_value = value - break - - if id_value is not None: - if scope == "global": - if id_value in global_ids: - prev_file, prev_line, prev_tag = global_ids[ - id_value - ] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " - f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" - ) - else: - global_ids[id_value] = ( - xml_file.relative_to(self.unpacked_dir), - elem.sourceline, - tag, - ) - elif scope == "file": - key = (tag, attr_name) - if key not in file_ids: - file_ids[key] = {} - - if id_value in file_ids[key]: - prev_line = file_ids[key][id_value] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " - f"(first occurrence at line {prev_line})" - ) - else: - file_ids[key][id_value] = elem.sourceline - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} ID uniqueness violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All required IDs are unique") - return True - - def validate_file_references(self): - errors = [] - - rels_files = list(self.unpacked_dir.rglob("*.rels")) - - if not rels_files: - if self.verbose: - print("PASSED - No .rels files found") - return True - - all_files = [] - for file_path in self.unpacked_dir.rglob("*"): - if ( - file_path.is_file() - and file_path.name != "[Content_Types].xml" - and not file_path.name.endswith(".rels") - ): - all_files.append(file_path.resolve()) - - all_referenced_files = set() - - if self.verbose: - print( - f"Found {len(rels_files)} .rels files and {len(all_files)} target files" - ) - - for rels_file in rels_files: - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - rels_dir = rels_file.parent - - referenced_files = set() - broken_refs = [] - - for rel in rels_root.findall( - ".//ns:Relationship", - namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, - ): - target = rel.get("Target") - if rel.get("TargetMode") == "External": - continue - if target and not target.startswith( - ("http", "mailto:") - ): - if target.startswith("/"): - target_path = self.unpacked_dir / target.lstrip("/") - elif rels_file.name == ".rels": - target_path = self.unpacked_dir / target - else: - base_dir = rels_dir.parent - target_path = base_dir / target - - try: - target_path = target_path.resolve() - if target_path.exists() and target_path.is_file(): - referenced_files.add(target_path) - all_referenced_files.add(target_path) - else: - broken_refs.append((target, rel.sourceline)) - except (OSError, ValueError): - broken_refs.append((target, rel.sourceline)) - - if broken_refs: - rel_path = rels_file.relative_to(self.unpacked_dir) - for broken_ref, line_num in broken_refs: - errors.append( - f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" - ) - - except Exception as e: - rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append(f" Error parsing {rel_path}: {e}") - - unreferenced_files = set(all_files) - all_referenced_files - - if unreferenced_files: - for unref_file in sorted(unreferenced_files): - unref_rel_path = unref_file.relative_to(self.unpacked_dir) - errors.append(f" Unreferenced file: {unref_rel_path}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship validation errors:") - for error in errors: - print(error) - print( - "CRITICAL: These errors will cause the document to appear corrupt. " - + "Broken references MUST be fixed, " - + "and unreferenced files MUST be referenced or removed." - ) - return False - else: - if self.verbose: - print( - "PASSED - All references are valid and all files are properly referenced" - ) - return True - - def validate_all_relationship_ids(self): - import lxml.etree - - errors = [] - - for xml_file in self.xml_files: - if xml_file.suffix == ".rels": - continue - - rels_dir = xml_file.parent / "_rels" - rels_file = rels_dir / f"{xml_file.name}.rels" - - if not rels_file.exists(): - continue - - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - rid_to_type = {} - - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rid = rel.get("Id") - rel_type = rel.get("Type", "") - if rid: - if rid in rid_to_type: - rels_rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append( - f" {rels_rel_path}: Line {rel.sourceline}: " - f"Duplicate relationship ID '{rid}' (IDs must be unique)" - ) - type_name = ( - rel_type.split("/")[-1] if "/" in rel_type else rel_type - ) - rid_to_type[rid] = type_name - - xml_root = lxml.etree.parse(str(xml_file)).getroot() - - r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE - rid_attrs_to_check = ["id", "embed", "link"] - for elem in xml_root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - for attr_name in rid_attrs_to_check: - rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") - if not rid_attr: - continue - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - elem_name = ( - elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag - ) - - if rid_attr not in rid_to_type: - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " - f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" - ) - elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: - expected_type = self._get_expected_relationship_type( - elem_name - ) - if expected_type: - actual_type = rid_to_type[rid_attr] - if expected_type not in actual_type.lower(): - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " - f"but should point to a '{expected_type}' relationship" - ) - - except Exception as e: - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - errors.append(f" Error processing {xml_rel_path}: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship ID reference errors:") - for error in errors: - print(error) - print("\nThese ID mismatches will cause the document to appear corrupt!") - return False - else: - if self.verbose: - print("PASSED - All relationship ID references are valid") - return True - - def _get_expected_relationship_type(self, element_name): - elem_lower = element_name.lower() - - if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: - return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] - - if elem_lower.endswith("id") and len(elem_lower) > 2: - prefix = elem_lower[:-2] - if prefix.endswith("master"): - return prefix.lower() - elif prefix.endswith("layout"): - return prefix.lower() - else: - if prefix == "sld": - return "slide" - return prefix.lower() - - if elem_lower.endswith("reference") and len(elem_lower) > 9: - prefix = elem_lower[:-9] - return prefix.lower() - - return None - - def validate_content_types(self): - errors = [] - - content_types_file = self.unpacked_dir / "[Content_Types].xml" - if not content_types_file.exists(): - print("FAILED - [Content_Types].xml file not found") - return False - - try: - root = lxml.etree.parse(str(content_types_file)).getroot() - declared_parts = set() - declared_extensions = set() - - for override in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" - ): - part_name = override.get("PartName") - if part_name is not None: - declared_parts.add(part_name.lstrip("/")) - - for default in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" - ): - extension = default.get("Extension") - if extension is not None: - declared_extensions.add(extension.lower()) - - declarable_roots = { - "sld", - "sldLayout", - "sldMaster", - "presentation", - "document", - "workbook", - "worksheet", - "theme", - } - - media_extensions = { - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - "bmp": "image/bmp", - "tiff": "image/tiff", - "wmf": "image/x-wmf", - "emf": "image/x-emf", - } - - all_files = list(self.unpacked_dir.rglob("*")) - all_files = [f for f in all_files if f.is_file()] - - for xml_file in self.xml_files: - path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( - "\\", "/" - ) - - if any( - skip in path_str - for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] - ): - continue - - try: - root_tag = lxml.etree.parse(str(xml_file)).getroot().tag - root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag - - if root_name in declarable_roots and path_str not in declared_parts: - errors.append( - f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" - ) - - except Exception: - continue - - for file_path in all_files: - if file_path.suffix.lower() in {".xml", ".rels"}: - continue - if file_path.name == "[Content_Types].xml": - continue - if "_rels" in file_path.parts or "docProps" in file_path.parts: - continue - - extension = file_path.suffix.lstrip(".").lower() - if extension and extension not in declared_extensions: - if extension in media_extensions: - relative_path = file_path.relative_to(self.unpacked_dir) - errors.append( - f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' - ) - - except Exception as e: - errors.append(f" Error parsing [Content_Types].xml: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} content type declaration errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print( - "PASSED - All content files are properly declared in [Content_Types].xml" - ) - return True - - def validate_file_against_xsd(self, xml_file, verbose=False): - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - - is_valid, current_errors = self._validate_single_file_xsd( - xml_file, unpacked_dir - ) - - if is_valid is None: - return None, set() - elif is_valid: - return True, set() - - original_errors = self._get_original_file_errors(xml_file) - - assert current_errors is not None - new_errors = current_errors - original_errors - - new_errors = { - e for e in new_errors - if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) - } - - if new_errors: - if verbose: - relative_path = xml_file.relative_to(unpacked_dir) - print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") - for error in list(new_errors)[:3]: - truncated = error[:250] + "..." if len(error) > 250 else error - print(f" - {truncated}") - return False, new_errors - else: - if verbose: - print( - f"PASSED - No new errors (original had {len(current_errors)} errors)" - ) - return True, set() - - def validate_against_xsd(self): - new_errors = [] - original_error_count = 0 - valid_count = 0 - skipped_count = 0 - - for xml_file in self.xml_files: - relative_path = str(xml_file.relative_to(self.unpacked_dir)) - is_valid, new_file_errors = self.validate_file_against_xsd( - xml_file, verbose=False - ) - - if is_valid is None: - skipped_count += 1 - continue - elif is_valid and not new_file_errors: - valid_count += 1 - continue - elif is_valid: - original_error_count += 1 - valid_count += 1 - continue - - new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") - for error in list(new_file_errors)[:3]: - new_errors.append( - f" - {error[:250]}..." if len(error) > 250 else f" - {error}" - ) - - if self.verbose: - print(f"Validated {len(self.xml_files)} files:") - print(f" - Valid: {valid_count}") - print(f" - Skipped (no schema): {skipped_count}") - if original_error_count: - print(f" - With original errors (ignored): {original_error_count}") - print( - f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" - ) - - if new_errors: - print("\nFAILED - Found NEW validation errors:") - for error in new_errors: - print(error) - return False - else: - if self.verbose: - print("\nPASSED - No new XSD validation errors introduced") - return True - - def _get_schema_path(self, xml_file): - if xml_file.name in self.SCHEMA_MAPPINGS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] - - if xml_file.suffix == ".rels": - return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] - - if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] - - if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] - - if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] - - return None - - def _clean_ignorable_namespaces(self, xml_doc): - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - for elem in xml_copy.iter(): - attrs_to_remove = [] - - for attr in elem.attrib: - if "{" in attr: - ns = attr.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - attrs_to_remove.append(attr) - - for attr in attrs_to_remove: - del elem.attrib[attr] - - self._remove_ignorable_elements(xml_copy) - - return lxml.etree.ElementTree(xml_copy) - - def _remove_ignorable_elements(self, root): - elements_to_remove = [] - - for elem in list(root): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - - tag_str = str(elem.tag) - if tag_str.startswith("{"): - ns = tag_str.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - elements_to_remove.append(elem) - continue - - self._remove_ignorable_elements(elem) - - for elem in elements_to_remove: - root.remove(elem) - - def _preprocess_for_mc_ignorable(self, xml_doc): - root = xml_doc.getroot() - - if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: - del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] - - return xml_doc - - def _preprocess_for_schema(self, xml_doc, relative_path): - return xml_doc - - def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): - schema_path = schema_path or self._get_schema_path(xml_file) - if not schema_path: - return None, None - - try: - schema = _load_schema(str(schema_path)) - - with open(xml_file, "r") as f: - xml_doc = lxml.etree.parse(f) - - xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) - xml_doc = self._preprocess_for_mc_ignorable(xml_doc) - - relative_path = xml_file.relative_to(base_path) - if ( - relative_path.parts - and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS - ): - xml_doc = self._clean_ignorable_namespaces(xml_doc) - - xml_doc = self._preprocess_for_schema(xml_doc, relative_path) - - if schema.validate(xml_doc): - return True, set() - else: - errors = set() - for error in schema.error_log: - errors.add(error.message) - return False, errors - - except Exception as e: - return False, {str(e)} - - def _get_original_file_errors(self, xml_file, schema_path=None): - if self.original_file is None: - return set() - - import tempfile - import zipfile - - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - relative_path = xml_file.relative_to(unpacked_dir) - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - original_xml_file = temp_path / relative_path - - if not original_xml_file.exists(): - return set() - - is_valid, errors = self._validate_single_file_xsd( - original_xml_file, temp_path, schema_path=schema_path - ) - return errors if errors else set() - - def _remove_template_tags_from_text_nodes(self, xml_doc): - warnings = [] - template_pattern = re.compile(r"\{\{[^}]*\}\}") - - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - def process_text_content(text, content_type): - if not text: - return text - matches = list(template_pattern.finditer(text)) - if matches: - for match in matches: - warnings.append( - f"Found template tag in {content_type}: {match.group()}" - ) - return template_pattern.sub("", text) - return text - - for elem in xml_copy.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag_str = str(elem.tag) - if tag_str.endswith("}t") or tag_str == "t": - continue - - elem.text = process_text_content(elem.text, "text content") - elem.tail = process_text_content(elem.tail, "tail content") - - return lxml.etree.ElementTree(xml_copy), warnings - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py deleted file mode 100644 index 0d18b6979a..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/docx.py +++ /dev/null @@ -1,466 +0,0 @@ -""" -Validator for Word document XML files against XSD schemas. -""" - -import random -import re -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom -import lxml.etree - -from helpers import safe_extract - -from .base import BaseSchemaValidator - - -class DOCXSchemaValidator(BaseSchemaValidator): - - WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" - W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" - - ELEMENT_RELATIONSHIP_TYPES = {} - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_whitespace_preservation(): - all_valid = False - - if not self.validate_deletions(): - all_valid = False - - if not self.validate_insertions(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_id_constraints(): - all_valid = False - - if not self.validate_comment_markers(): - all_valid = False - - self.compare_paragraph_counts() - - return all_valid - - def validate_whitespace_preservation(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): - if elem.text: - text = elem.text - if re.search(r"^[ \t\n\r]", text) or re.search( - r"[ \t\n\r]$", text - ): - xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" - if ( - xml_space_attr not in elem.attrib - or elem.attrib[xml_space_attr] != "preserve" - ): - text_preview = ( - repr(text)[:50] + "..." - if len(repr(text)) > 50 - else repr(text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} whitespace preservation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All whitespace is properly preserved") - return True - - def validate_deletions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): - if t_elem.text: - text_preview = ( - repr(t_elem.text)[:50] + "..." - if len(repr(t_elem.text)) > 50 - else repr(t_elem.text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {t_elem.sourceline}: found within : {text_preview}" - ) - - for instr_elem in root.xpath( - ".//w:del//w:instrText", namespaces=namespaces - ): - text_preview = ( - repr(instr_elem.text or "")[:50] + "..." - if len(repr(instr_elem.text or "")) > 50 - else repr(instr_elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} deletion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:t elements found within w:del elements") - return True - - def count_paragraphs_in_unpacked(self): - count = 0 - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - except Exception as e: - print(f"Error counting paragraphs in unpacked document: {e}") - - return count - - def count_paragraphs_in_original(self): - original = self.original_file - if original is None: - return 0 - - count = 0 - - try: - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(original, "r") as zip_ref: - safe_extract(zip_ref, Path(temp_dir)) - - doc_xml_path = temp_dir + "/word/document.xml" - root = lxml.etree.parse(doc_xml_path).getroot() - - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - - except Exception as e: - print(f"Error counting paragraphs in original document: {e}") - - return count - - def validate_insertions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - invalid_elements = root.xpath( - ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces - ) - - for elem in invalid_elements: - text_preview = ( - repr(elem.text or "")[:50] + "..." - if len(repr(elem.text or "")) > 50 - else repr(elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: within : {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} insertion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:delText elements within w:ins elements") - return True - - def compare_paragraph_counts(self): - new_count = self.count_paragraphs_in_unpacked() - if self.original_file is None: - print(f"\nParagraphs: {new_count}") - return - - original_count = self.count_paragraphs_in_original() - diff = new_count - original_count - diff_str = f"+{diff}" if diff > 0 else str(diff) - print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") - - def _parse_id_value(self, val: str, base: int = 16) -> int: - return int(val, base) - - def validate_id_constraints(self): - errors = [] - para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" - durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" - - for xml_file in self.xml_files: - try: - for elem in lxml.etree.parse(str(xml_file)).iter(): - if val := elem.get(para_id_attr): - try: - if self._parse_id_value(val, base=16) >= 0x80000000: - errors.append( - f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"paraId={val} is not valid hex" - ) - - if val := elem.get(durable_id_attr): - if xml_file.name == "numbering.xml": - try: - if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} must be decimal in numbering.xml" - ) - else: - try: - if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} is not valid hex" - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} ID constraint violations:") - for e in errors: - print(e) - elif self.verbose: - print("PASSED - All paraId/durableId values within constraints") - return not errors - - def validate_comment_markers(self): - errors = [] - - document_xml = None - comments_xml = None - for xml_file in self.xml_files: - if xml_file.name == "document.xml" and "word" in str(xml_file): - document_xml = xml_file - elif xml_file.name == "comments.xml": - comments_xml = xml_file - - if not document_xml: - if self.verbose: - print("PASSED - No document.xml found (skipping comment validation)") - return True - - try: - doc_root = lxml.etree.parse(str(document_xml)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - range_starts = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeStart", namespaces=namespaces - ) - } - range_ends = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeEnd", namespaces=namespaces - ) - } - references = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentReference", namespaces=namespaces - ) - } - - orphaned_ends = range_ends - range_starts - for comment_id in sorted( - orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' - ) - - orphaned_starts = range_starts - range_ends - for comment_id in sorted( - orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' - ) - - comment_ids = set() - if comments_xml and comments_xml.exists(): - comments_root = lxml.etree.parse(str(comments_xml)).getroot() - comment_ids = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in comments_root.xpath( - ".//w:comment", namespaces=namespaces - ) - } - - marker_ids = range_starts | range_ends | references - invalid_refs = marker_ids - comment_ids - for comment_id in sorted( - invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - if comment_id: - errors.append( - f' document.xml: marker id="{comment_id}" references non-existent comment' - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append(f" Error parsing XML: {e}") - - if errors: - print(f"FAILED - {len(errors)} comment marker violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All comment markers properly paired") - return True - - def repair(self) -> int: - repairs = super().repair() - repairs += self.repair_durableId() - return repairs - - def repair_durableId(self) -> int: - DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") - repairs = 0 - renames: dict = {} - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - is_numbering = xml_file.name == "numbering.xml" - base = 10 if is_numbering else 16 - pending = [] - seen_in_file = set() - modified = False - - for elem in dom.getElementsByTagName("*"): - for attr_name in DURABLE_ID_ATTRS: - if not elem.hasAttribute(attr_name): - continue - - durable_id = elem.getAttribute(attr_name) - try: - key = self._parse_id_value(durable_id, base=base) - needs_repair = key >= 0x7FFFFFFF - except ValueError: - key = durable_id - needs_repair = True - - if needs_repair: - if key in seen_in_file: - value = random.randint(1, 0x7FFFFFFE) - else: - seen_in_file.add(key) - if key not in renames: - renames[key] = random.randint(1, 0x7FFFFFFE) - value = renames[key] - new_id = str(value) if is_numbering else f"{value:08X}" - - elem.setAttribute(attr_name, new_id) - pending.append( - f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" - ) - modified = True - - if modified: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py deleted file mode 100644 index 7b53d0d3e4..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/pptx.py +++ /dev/null @@ -1,441 +0,0 @@ -""" -Validator for PowerPoint presentation XML files against XSD schemas. -""" - -import re -from pathlib import Path - -from helpers import opc_target, rels_source_part, safe_extract - -from .base import BaseSchemaValidator - - -class PPTXSchemaValidator(BaseSchemaValidator): - - PRESENTATIONML_NAMESPACE = ( - "http://schemas.openxmlformats.org/presentationml/2006/main" - ) - - ELEMENT_RELATIONSHIP_TYPES = { - "sldid": "slide", - "sldmasterid": "slidemaster", - "notesmasterid": "notesmaster", - "sldlayoutid": "slidelayout", - "themeid": "theme", - "tablestyleid": "tablestyles", - } - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_uuid_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_slide_layout_ids(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_notes_slide_references(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_no_duplicate_slide_layouts(): - all_valid = False - - if not self.validate_master_theme_uniqueness(): - all_valid = False - - if not self.validate_charts(): - all_valid = False - - if not self.validate_slides(): - all_valid = False - - return all_valid - - def _package_map(self) -> dict: - wanted = [] - wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) - wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) - wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) - for group in ("slideMasters", "notesMasters", "handoutMasters"): - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) - return { - p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() - for p in wanted - if p.is_file() - } - - def validate_master_theme_uniqueness(self): - from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes - - shared = live_shared_master_themes(self._package_map()) - if shared: - print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") - for message in shared: - print(f" {message}") - if any(m.startswith(_NOTES_MASTERS) for m in shared): - print(" Fix: in ppt/presentation.xml, move back to " - "directly after . PowerPoint reads that happily.") - else: - print(" Fix: give each master its own theme part.") - return False - - if self.verbose: - print("PASSED - No master shares a theme part in a way PowerPoint refuses") - return True - - def validate_charts(self): - from helpers.pptx_chart import find_chart_problems - - problems = find_chart_problems(self._package_map()) - if problems: - print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") - for message in problems: - print(f" {message}") - return False - - if self.verbose: - print("PASSED - Charts satisfy the constraints PowerPoint enforces") - return True - - def _original_slide_defects(self, schema) -> set[str]: - import tempfile - import zipfile - - from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors - - if self.original_file is None: - return set() - - found: set[str] = set() - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - try: - with zipfile.ZipFile(self.original_file, "r") as zf: - safe_extract(zf, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - for part in sorted(temp_path.rglob("*.xml")): - relative = part.relative_to(temp_path).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - part.resolve(), temp_path.resolve(), schema_path=schema - ) - if ok is None or ok or not errors: - continue - found |= set(fatal_slide_errors(set(errors))) - return found - - def validate_slides(self): - from helpers.pptx_slide import ( - SLIDE_PART_RE, - fatal_slide_errors, - is_schema_verdict, - ) - - schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] - inherited = self._original_slide_defects(schema) - problems: list[str] = [] - broken: list[str] = [] - - for xml_file in self.xml_files: - relative = xml_file.relative_to(self.unpacked_dir).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema - ) - if ok is None or not errors: - continue - - unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] - if unreadable: - broken.extend(unreadable) - continue - if ok: - continue - - for message in fatal_slide_errors(set(errors)): - if message in inherited: - continue - problems.append(f"{relative}: {message}") - - if broken: - print(f"FAILED - Could not check {len(broken)} slide part(s):") - for message in sorted(broken): - print(f" {message[:240]}") - - if problems: - print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") - for message in sorted(problems): - print(f" {message[:240]}") - - if broken or problems: - return False - - if self.verbose: - print("PASSED - Slide XML has none of the defects PowerPoint refuses") - return True - - def _get_schema_path(self, xml_file): - if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): - return None - return super()._get_schema_path(xml_file) - - def _preprocess_for_schema(self, xml_doc, relative_path): - if relative_path.as_posix() != "ppt/presentation.xml": - return xml_doc - - root = xml_doc.getroot() - ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" - notes = root.find(f"{ns}notesMasterIdLst") - slides = root.find(f"{ns}sldIdLst") - if notes is None or slides is None: - return xml_doc - - children = list(root) - if children.index(notes) < children.index(slides): - return xml_doc - - root.remove(notes) - root.insert(list(root).index(slides), notes) - return xml_doc - - def validate_uuid_ids(self): - import lxml.etree - - errors = [] - uuid_pattern = re.compile( - r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" - ) - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(): - for attr, value in elem.attrib.items(): - attr_name = attr.split("}")[-1].lower() - if attr_name == "id" or attr_name.endswith("id"): - if self._looks_like_uuid(value): - if not uuid_pattern.match(value): - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} UUID ID validation errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All UUID-like IDs contain valid hex values") - return True - - def _looks_like_uuid(self, value): - clean_value = value.strip("{}()").replace("-", "") - return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) - - def validate_slide_layout_ids(self): - import lxml.etree - - errors = [] - - slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) - - if not slide_masters: - if self.verbose: - print("PASSED - No slide masters found") - return True - - for slide_master in slide_masters: - try: - root = lxml.etree.parse(str(slide_master)).getroot() - - rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" - - if not rels_file.exists(): - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" - ) - continue - - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - valid_layout_rids = set() - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "slideLayout" in rel_type: - valid_layout_rids.add(rel.get("Id")) - - for sld_layout_id in root.findall( - f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" - ): - r_id = sld_layout_id.get( - f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" - ) - layout_id = sld_layout_id.get("id") - - if r_id and r_id not in valid_layout_rids: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " - f"references r:id='{r_id}' which is not found in slide layout relationships" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") - for error in errors: - print(error) - print( - "Remove invalid references or add missing slide layouts to the relationships file." - ) - return False - else: - if self.verbose: - print("PASSED - All slide layout IDs reference valid slide layouts") - return True - - def validate_no_duplicate_slide_layouts(self): - import lxml.etree - - errors = [] - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - layout_rels = [ - rel - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ) - if "slideLayout" in rel.get("Type", "") - ] - - if len(layout_rels) > 1: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" - ) - - except Exception as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print("FAILED - Found slides with duplicate slideLayout references:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All slides have exactly one slideLayout reference") - return True - - def validate_notes_slide_references(self): - import lxml.etree - - errors = [] - notes_slide_references = {} - - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - if not slide_rels_files: - if self.verbose: - print("PASSED - No slide relationship files found") - return True - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "notesSlide" in rel_type: - part = opc_target( - rel.get("Target", ""), - rels_source_part(rels_file, self.unpacked_dir), - rel.get("TargetMode", ""), - ) - if part: - slide_name = rels_file.stem.replace( - ".xml", "" - ) - - notes_slide_references.setdefault(part, []).append( - (slide_name, rels_file) - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - for target, references in notes_slide_references.items(): - if len(references) > 1: - slide_names = [ref[0] for ref in references] - errors.append( - f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" - ) - for slide_name, rels_file in references: - errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") - - if errors: - print( - f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" - ) - for error in errors: - print(error) - print("Each slide may optionally have its own slide file.") - return False - else: - if self.verbose: - print("PASSED - All notes slide references are unique") - return True - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py deleted file mode 100644 index 18d0c68be9..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/office/validators/redlining.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Validator for tracked changes in Word documents. - -Detects untracked edits in word/document.xml: text that differs from the -original without a / wrapper recording it. The tracked changes -that are new relative to the original are undone, and the result is compared -against the original; whatever text still differs was edited without being -tracked. - -Only the document body is compared. Headers, footers, footnotes and endnotes -are separate parts and are not checked. -""" - -import subprocess -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import rendered_text, safe_extract - - -class RedliningValidator: - - def __init__(self, unpacked_dir, original_docx, verbose=False): - self.unpacked_dir = Path(unpacked_dir) - self.original_docx = Path(original_docx) - self.verbose = verbose - self.namespaces = { - "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - } - - def repair(self) -> int: - return 0 - - def validate(self): - modified_file = self.unpacked_dir / "word" / "document.xml" - if not modified_file.exists(): - print(f"FAILED - Modified document.xml not found at {modified_file}") - return False - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_docx, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except Exception as e: - print(f"FAILED - Error unpacking original docx: {e}") - return False - - original_file = temp_path / "word" / "document.xml" - if not original_file.exists(): - print( - f"FAILED - Original document.xml not found in {self.original_docx}" - ) - return False - - try: - modified_tree = ET.parse(modified_file) - modified_root = modified_tree.getroot() - original_tree = ET.parse(original_file) - original_root = original_tree.getroot() - except (ET.ParseError, DefusedXmlException) as e: - print(f"FAILED - Error parsing XML files: {e}") - return False - - new_changes = self._new_tracked_changes(original_root, modified_root) - self._remove_tracked_changes(modified_root, new_changes) - - modified_text = self._extract_text_content(modified_root) - original_text = self._extract_text_content(original_root) - - if modified_text != original_text: - error_message = self._generate_detailed_diff( - original_text, modified_text - ) - print(error_message) - return False - - if self.verbose: - print( - f"PASSED - All {len(new_changes)} change(s) against the original " - "are properly tracked" - ) - return True - - def _tracked_change_elements(self, root): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] - - def _rendered_text(self, elem): - preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" - return rendered_text(elem.text or "", preserve) - - def _text_elements(self, elem): - w = self.namespaces["w"] - return [ - node - for node in elem.iter() - if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") - ] - - def _tracked_change_key(self, elem): - w = self.namespaces["w"] - text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) - return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) - - def _new_tracked_changes(self, original_root, modified_root): - original = self._tracked_change_elements(original_root) - modified = self._tracked_change_elements(modified_root) - - pool = {} - for elem in original: - pool.setdefault(self._tracked_change_key(elem), []).append(elem) - - matched, leftover = set(), [] - for elem in modified: - bucket = pool.get(self._tracked_change_key(elem)) - if bucket: - matched.add(bucket.pop()) - else: - leftover.append(elem) - - def group(elem): - return self._tracked_change_key(elem)[:3] - - def text_of(elems): - return "".join(self._tracked_change_key(e)[3] for e in elems) - - unmatched_original = {} - for elem in original: - if elem not in matched: - unmatched_original.setdefault(group(elem), []).append(elem) - - by_group = {} - for elem in leftover: - by_group.setdefault(group(elem), []).append(elem) - - new = set() - for key, elems in by_group.items(): - rebuilt = text_of(elems) - if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): - continue - new.update(elems) - return new - - def _generate_detailed_diff(self, original_text, modified_text): - error_parts = [ - "FAILED - Document text doesn't match after removing the tracked changes", - "", - "Likely causes:", - " 1. Modified text inside another author's or tags", - " 2. Made edits without proper tracked changes", - " 3. Didn't nest inside when deleting another's insertion", - " 4. Rewrote another author's / and changed its text on", - " the way. A tracked change from the original is recognised by its", - " author, date and text; anything that doesn't reproduce one exactly", - " reads as new, and the text it carried is reported missing.", - "", - "For pre-redlined documents, use correct patterns:", - " - To reject another's INSERTION: Nest inside their ", - " - To reject PART of one: nest around only the runs you reject.", - " Their may be split around it, so long as the pieces keep", - " their author and date and still spell out the same text.", - " - To restore another's DELETION: Add new AFTER their ", - "", - ] - - git_diff = self._get_git_word_diff(original_text, modified_text) - if git_diff: - error_parts.extend(["Differences:", "============", git_diff]) - else: - error_parts.append("Unable to generate word diff (git not available)") - - return "\n".join(error_parts) - - def _get_git_word_diff(self, original_text, modified_text): - try: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - original_file = temp_path / "original.txt" - modified_file = temp_path / "modified.txt" - - original_file.write_text(original_text, encoding="utf-8") - modified_file.write_text(modified_text, encoding="utf-8") - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "--word-diff-regex=.", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - - if content_lines: - return "\n".join(content_lines) - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - return "\n".join(content_lines) - - except (subprocess.CalledProcessError, FileNotFoundError, Exception): - pass - - return None - - def _remove_tracked_changes(self, root, targets): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - - for parent in root.iter(): - to_remove = [] - for child in parent: - if child.tag == ins_tag and child in targets: - to_remove.append(child) - for elem in to_remove: - parent.remove(elem) - - deltext_tag = f"{{{self.namespaces['w']}}}delText" - t_tag = f"{{{self.namespaces['w']}}}t" - - for parent in root.iter(): - to_process = [] - for child in parent: - if child.tag == del_tag and child in targets: - to_process.append((child, list(parent).index(child))) - - for del_elem, del_index in reversed(to_process): - for elem in del_elem.iter(): - if elem.tag == deltext_tag: - elem.tag = t_tag - - for child in reversed(list(del_elem)): - parent.insert(del_index, child) - parent.remove(del_elem) - - def _extract_text_content(self, root): - p_tag = f"{{{self.namespaces['w']}}}p" - t_tag = f"{{{self.namespaces['w']}}}t" - - paragraphs = [] - for p_elem in root.findall(f".//{p_tag}"): - text_parts = [] - for t_elem in p_elem.findall(f".//{t_tag}"): - text_parts.append(self._rendered_text(t_elem)) - paragraph_text = "".join(text_parts) - if paragraph_text: - paragraphs.append(paragraph_text) - - return "\n".join(paragraphs) - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml deleted file mode 100644 index cd01a7d715..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/comments.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml deleted file mode 100644 index 411003cc48..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtended.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml deleted file mode 100644 index f5572d7108..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml deleted file mode 100644 index 32f1629f2a..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/commentsIds.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml b/src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml deleted file mode 100644 index 3803d2de0f..0000000000 --- a/src/crates/assembly/core/builtin_skills/docx/scripts/templates/people.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt b/src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt deleted file mode 100644 index c55ab42224..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -© 2025 Anthropic, PBC. All rights reserved. - -LICENSE: Use of these materials (including all code, prompts, assets, files, -and other components of this Skill) is governed by your agreement with -Anthropic regarding use of Anthropic's services. If no separate agreement -exists, use is governed by Anthropic's Consumer Terms of Service or -Commercial Terms of Service, as applicable: -https://www.anthropic.com/legal/consumer-terms -https://www.anthropic.com/legal/commercial-terms -Your applicable agreement is referred to as the "Agreement." "Services" are -as defined in the Agreement. - -ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the -contrary, users may not: - -- Extract these materials from the Services or retain copies of these - materials outside the Services -- Reproduce or copy these materials, except for temporary copies created - automatically during authorized use of the Services -- Create derivative works based on these materials -- Distribute, sublicense, or transfer these materials to any third party -- Make, offer to sell, sell, or import any inventions embodied in these - materials -- Reverse engineer, decompile, or disassemble these materials - -The receipt, viewing, or possession of these materials does not convey or -imply any license or right beyond those expressly granted above. - -Anthropic retains all right, title, and interest in these materials, -including all copyrights, patents, and other intellectual property rights. diff --git a/src/crates/assembly/core/builtin_skills/pdf/SKILL.md b/src/crates/assembly/core/builtin_skills/pdf/SKILL.md deleted file mode 100644 index d3e046a5ae..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/SKILL.md +++ /dev/null @@ -1,314 +0,0 @@ ---- -name: pdf -description: Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill. -license: Proprietary. LICENSE.txt has complete terms ---- - -# PDF Processing Guide - -## Overview - -This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions. - -## Quick Start - -```python -from pypdf import PdfReader, PdfWriter - -# Read a PDF -reader = PdfReader("document.pdf") -print(f"Pages: {len(reader.pages)}") - -# Extract text -text = "" -for page in reader.pages: - text += page.extract_text() -``` - -## Python Libraries - -### pypdf - Basic Operations - -#### Merge PDFs -```python -from pypdf import PdfWriter, PdfReader - -writer = PdfWriter() -for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]: - reader = PdfReader(pdf_file) - for page in reader.pages: - writer.add_page(page) - -with open("merged.pdf", "wb") as output: - writer.write(output) -``` - -#### Split PDF -```python -reader = PdfReader("input.pdf") -for i, page in enumerate(reader.pages): - writer = PdfWriter() - writer.add_page(page) - with open(f"page_{i+1}.pdf", "wb") as output: - writer.write(output) -``` - -#### Extract Metadata -```python -reader = PdfReader("document.pdf") -meta = reader.metadata -print(f"Title: {meta.title}") -print(f"Author: {meta.author}") -print(f"Subject: {meta.subject}") -print(f"Creator: {meta.creator}") -``` - -#### Rotate Pages -```python -reader = PdfReader("input.pdf") -writer = PdfWriter() - -page = reader.pages[0] -page.rotate(90) # Rotate 90 degrees clockwise -writer.add_page(page) - -with open("rotated.pdf", "wb") as output: - writer.write(output) -``` - -### pdfplumber - Text and Table Extraction - -#### Extract Text with Layout -```python -import pdfplumber - -with pdfplumber.open("document.pdf") as pdf: - for page in pdf.pages: - text = page.extract_text() - print(text) -``` - -#### Extract Tables -```python -with pdfplumber.open("document.pdf") as pdf: - for i, page in enumerate(pdf.pages): - tables = page.extract_tables() - for j, table in enumerate(tables): - print(f"Table {j+1} on page {i+1}:") - for row in table: - print(row) -``` - -#### Advanced Table Extraction -```python -import pandas as pd - -with pdfplumber.open("document.pdf") as pdf: - all_tables = [] - for page in pdf.pages: - tables = page.extract_tables() - for table in tables: - if table: # Check if table is not empty - df = pd.DataFrame(table[1:], columns=table[0]) - all_tables.append(df) - -# Combine all tables -if all_tables: - combined_df = pd.concat(all_tables, ignore_index=True) - combined_df.to_excel("extracted_tables.xlsx", index=False) -``` - -### reportlab - Create PDFs - -#### Basic PDF Creation -```python -from reportlab.lib.pagesizes import letter -from reportlab.pdfgen import canvas - -c = canvas.Canvas("hello.pdf", pagesize=letter) -width, height = letter - -# Add text -c.drawString(100, height - 100, "Hello World!") -c.drawString(100, height - 120, "This is a PDF created with reportlab") - -# Add a line -c.line(100, height - 140, 400, height - 140) - -# Save -c.save() -``` - -#### Create PDF with Multiple Pages -```python -from reportlab.lib.pagesizes import letter -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak -from reportlab.lib.styles import getSampleStyleSheet - -doc = SimpleDocTemplate("report.pdf", pagesize=letter) -styles = getSampleStyleSheet() -story = [] - -# Add content -title = Paragraph("Report Title", styles['Title']) -story.append(title) -story.append(Spacer(1, 12)) - -body = Paragraph("This is the body of the report. " * 20, styles['Normal']) -story.append(body) -story.append(PageBreak()) - -# Page 2 -story.append(Paragraph("Page 2", styles['Heading1'])) -story.append(Paragraph("Content for page 2", styles['Normal'])) - -# Build PDF -doc.build(story) -``` - -#### Subscripts and Superscripts - -**IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes. - -Instead, use ReportLab's XML markup tags in Paragraph objects: -```python -from reportlab.platypus import Paragraph -from reportlab.lib.styles import getSampleStyleSheet - -styles = getSampleStyleSheet() - -# Subscripts: use tag -chemical = Paragraph("H2O", styles['Normal']) - -# Superscripts: use tag -squared = Paragraph("x2 + y2", styles['Normal']) -``` - -For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts. - -## Command-Line Tools - -### pdftotext (poppler-utils) -```bash -# Extract text -pdftotext input.pdf output.txt - -# Extract text preserving layout -pdftotext -layout input.pdf output.txt - -# Extract specific pages -pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5 -``` - -### qpdf -```bash -# Merge PDFs -qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf - -# Split pages -qpdf input.pdf --pages . 1-5 -- pages1-5.pdf -qpdf input.pdf --pages . 6-10 -- pages6-10.pdf - -# Rotate pages -qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees - -# Remove password -qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf -``` - -### pdftk (if available) -```bash -# Merge -pdftk file1.pdf file2.pdf cat output merged.pdf - -# Split -pdftk input.pdf burst - -# Rotate -pdftk input.pdf rotate 1east output rotated.pdf -``` - -## Common Tasks - -### Extract Text from Scanned PDFs -```python -# Requires: pip install pytesseract pdf2image -import pytesseract -from pdf2image import convert_from_path - -# Convert PDF to images -images = convert_from_path('scanned.pdf') - -# OCR each page -text = "" -for i, image in enumerate(images): - text += f"Page {i+1}:\n" - text += pytesseract.image_to_string(image) - text += "\n\n" - -print(text) -``` - -### Add Watermark -```python -from pypdf import PdfReader, PdfWriter - -# Create watermark (or load existing) -watermark = PdfReader("watermark.pdf").pages[0] - -# Apply to all pages -reader = PdfReader("document.pdf") -writer = PdfWriter() - -for page in reader.pages: - page.merge_page(watermark) - writer.add_page(page) - -with open("watermarked.pdf", "wb") as output: - writer.write(output) -``` - -### Extract Images -```bash -# Using pdfimages (poppler-utils) -pdfimages -j input.pdf output_prefix - -# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc. -``` - -### Password Protection -```python -from pypdf import PdfReader, PdfWriter - -reader = PdfReader("input.pdf") -writer = PdfWriter() - -for page in reader.pages: - writer.add_page(page) - -# Add password -writer.encrypt("userpassword", "ownerpassword") - -with open("encrypted.pdf", "wb") as output: - writer.write(output) -``` - -## Quick Reference - -| Task | Best Tool | Command/Code | -|------|-----------|--------------| -| Merge PDFs | pypdf | `writer.add_page(page)` | -| Split PDFs | pypdf | One page per file | -| Extract text | pdfplumber | `page.extract_text()` | -| Extract tables | pdfplumber | `page.extract_tables()` | -| Create PDFs | reportlab | Canvas or Platypus | -| Command line merge | qpdf | `qpdf --empty --pages ...` | -| OCR scanned PDFs | pytesseract | Convert to image first | -| Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md | - -## Next Steps - -- For advanced pypdfium2 usage, see REFERENCE.md -- For JavaScript libraries (pdf-lib), see REFERENCE.md -- If you need to fill out a PDF form, follow the instructions in FORMS.md -- For troubleshooting guides, see REFERENCE.md diff --git a/src/crates/assembly/core/builtin_skills/pdf/forms.md b/src/crates/assembly/core/builtin_skills/pdf/forms.md deleted file mode 100644 index 6e7e1e0d9e..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/forms.md +++ /dev/null @@ -1,294 +0,0 @@ -**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.** - -If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: - `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions. - -# Fillable fields -If the PDF has fillable form fields: -- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format: -``` -[ - { - "field_id": (unique ID for the field), - "page": (page number, 1-based), - "rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page), - "type": ("text", "checkbox", "radio_group", or "choice"), - }, - // Checkboxes have "checked_value" and "unchecked_value" properties: - { - "field_id": (unique ID for the field), - "page": (page number, 1-based), - "type": "checkbox", - "checked_value": (Set the field to this value to check the checkbox), - "unchecked_value": (Set the field to this value to uncheck the checkbox), - }, - // Radio groups have a "radio_options" list with the possible choices. - { - "field_id": (unique ID for the field), - "page": (page number, 1-based), - "type": "radio_group", - "radio_options": [ - { - "value": (set the field to this value to select this radio option), - "rect": (bounding box for the radio button for this option) - }, - // Other radio options - ] - }, - // Multiple choice fields have a "choice_options" list with the possible choices: - { - "field_id": (unique ID for the field), - "page": (page number, 1-based), - "type": "choice", - "choice_options": [ - { - "value": (set the field to this value to select this option), - "text": (display text of the option) - }, - // Other choice options - ], - } -] -``` -- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory): -`python scripts/convert_pdf_to_images.py ` -Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates). -- Create a `field_values.json` file in this format with the values to be entered for each field: -``` -[ - { - "field_id": "last_name", // Must match the field_id from `extract_form_field_info.py` - "description": "The user's last name", - "page": 1, // Must match the "page" value in field_info.json - "value": "Simpson" - }, - { - "field_id": "Checkbox12", - "description": "Checkbox to be checked if the user is 18 or over", - "page": 1, - "value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options". - }, - // more fields -] -``` -- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF: -`python scripts/fill_fillable_fields.py ` -This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again. - -# Non-fillable fields -If the PDF doesn't have fillable form fields, you'll add text annotations. First try to extract coordinates from the PDF structure (more accurate), then fall back to visual estimation if needed. - -## Step 1: Try Structure Extraction First - -Run this script to extract text labels, lines, and checkboxes with their exact PDF coordinates: -`python scripts/extract_form_structure.py form_structure.json` - -This creates a JSON file containing: -- **labels**: Every text element with exact coordinates (x0, top, x1, bottom in PDF points) -- **lines**: Horizontal lines that define row boundaries -- **checkboxes**: Small square rectangles that are checkboxes (with center coordinates) -- **row_boundaries**: Row top/bottom positions calculated from horizontal lines - -**Check the results**: If `form_structure.json` has meaningful labels (text elements that correspond to form fields), use **Approach A: Structure-Based Coordinates**. If the PDF is scanned/image-based and has few or no labels, use **Approach B: Visual Estimation**. - ---- - -## Approach A: Structure-Based Coordinates (Preferred) - -Use this when `extract_form_structure.py` found text labels in the PDF. - -### A.1: Analyze the Structure - -Read form_structure.json and identify: - -1. **Label groups**: Adjacent text elements that form a single label (e.g., "Last" + "Name") -2. **Row structure**: Labels with similar `top` values are in the same row -3. **Field columns**: Entry areas start after label ends (x0 = label.x1 + gap) -4. **Checkboxes**: Use the checkbox coordinates directly from the structure - -**Coordinate system**: PDF coordinates where y=0 is at TOP of page, y increases downward. - -### A.2: Check for Missing Elements - -The structure extraction may not detect all form elements. Common cases: -- **Circular checkboxes**: Only square rectangles are detected as checkboxes -- **Complex graphics**: Decorative elements or non-standard form controls -- **Faded or light-colored elements**: May not be extracted - -If you see form fields in the PDF images that aren't in form_structure.json, you'll need to use **visual analysis** for those specific fields (see "Hybrid Approach" below). - -### A.3: Create fields.json with PDF Coordinates - -For each field, calculate entry coordinates from the extracted structure: - -**Text fields:** -- entry x0 = label x1 + 5 (small gap after label) -- entry x1 = next label's x0, or row boundary -- entry top = same as label top -- entry bottom = row boundary line below, or label bottom + row_height - -**Checkboxes:** -- Use the checkbox rectangle coordinates directly from form_structure.json -- entry_bounding_box = [checkbox.x0, checkbox.top, checkbox.x1, checkbox.bottom] - -Create fields.json using `pdf_width` and `pdf_height` (signals PDF coordinates): -```json -{ - "pages": [ - {"page_number": 1, "pdf_width": 612, "pdf_height": 792} - ], - "form_fields": [ - { - "page_number": 1, - "description": "Last name entry field", - "field_label": "Last Name", - "label_bounding_box": [43, 63, 87, 73], - "entry_bounding_box": [92, 63, 260, 79], - "entry_text": {"text": "Smith", "font_size": 10} - }, - { - "page_number": 1, - "description": "US Citizen Yes checkbox", - "field_label": "Yes", - "label_bounding_box": [260, 200, 280, 210], - "entry_bounding_box": [285, 197, 292, 205], - "entry_text": {"text": "X"} - } - ] -} -``` - -**Important**: Use `pdf_width`/`pdf_height` and coordinates directly from form_structure.json. - -### A.4: Validate Bounding Boxes - -Before filling, check your bounding boxes for errors: -`python scripts/check_bounding_boxes.py fields.json` - -This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. - ---- - -## Approach B: Visual Estimation (Fallback) - -Use this when the PDF is scanned/image-based and structure extraction found no usable text labels (e.g., all text shows as "(cid:X)" patterns). - -### B.1: Convert PDF to Images - -`python scripts/convert_pdf_to_images.py ` - -### B.2: Initial Field Identification - -Examine each page image to identify form sections and get **rough estimates** of field locations: -- Form field labels and their approximate positions -- Entry areas (lines, boxes, or blank spaces for text input) -- Checkboxes and their approximate locations - -For each field, note approximate pixel coordinates (they don't need to be precise yet). - -### B.3: Zoom Refinement (CRITICAL for accuracy) - -For each field, crop a region around the estimated position to refine coordinates precisely. - -**Create a zoomed crop using ImageMagick:** -```bash -magick -crop x++ +repage -``` - -Where: -- `, ` = top-left corner of crop region (use your rough estimate minus padding) -- `, ` = size of crop region (field area plus ~50px padding on each side) - -**Example:** To refine a "Name" field estimated around (100, 150): -```bash -magick images_dir/page_1.png -crop 300x80+50+120 +repage crops/name_field.png -``` - -(Note: if the `magick` command isn't available, try `convert` with the same arguments). - -**Examine the cropped image** to determine precise coordinates: -1. Identify the exact pixel where the entry area begins (after the label) -2. Identify where the entry area ends (before next field or edge) -3. Identify the top and bottom of the entry line/box - -**Convert crop coordinates back to full image coordinates:** -- full_x = crop_x + crop_offset_x -- full_y = crop_y + crop_offset_y - -Example: If the crop started at (50, 120) and the entry box starts at (52, 18) within the crop: -- entry_x0 = 52 + 50 = 102 -- entry_top = 18 + 120 = 138 - -**Repeat for each field**, grouping nearby fields into single crops when possible. - -### B.4: Create fields.json with Refined Coordinates - -Create fields.json using `image_width` and `image_height` (signals image coordinates): -```json -{ - "pages": [ - {"page_number": 1, "image_width": 1700, "image_height": 2200} - ], - "form_fields": [ - { - "page_number": 1, - "description": "Last name entry field", - "field_label": "Last Name", - "label_bounding_box": [120, 175, 242, 198], - "entry_bounding_box": [255, 175, 720, 218], - "entry_text": {"text": "Smith", "font_size": 10} - } - ] -} -``` - -**Important**: Use `image_width`/`image_height` and the refined pixel coordinates from the zoom analysis. - -### B.5: Validate Bounding Boxes - -Before filling, check your bounding boxes for errors: -`python scripts/check_bounding_boxes.py fields.json` - -This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling. - ---- - -## Hybrid Approach: Structure + Visual - -Use this when structure extraction works for most fields but misses some elements (e.g., circular checkboxes, unusual form controls). - -1. **Use Approach A** for fields that were detected in form_structure.json -2. **Convert PDF to images** for visual analysis of missing fields -3. **Use zoom refinement** (from Approach B) for the missing fields -4. **Combine coordinates**: For fields from structure extraction, use `pdf_width`/`pdf_height`. For visually-estimated fields, you must convert image coordinates to PDF coordinates: - - pdf_x = image_x * (pdf_width / image_width) - - pdf_y = image_y * (pdf_height / image_height) -5. **Use a single coordinate system** in fields.json - convert all to PDF coordinates with `pdf_width`/`pdf_height` - ---- - -## Step 2: Validate Before Filling - -**Always validate bounding boxes before filling:** -`python scripts/check_bounding_boxes.py fields.json` - -This checks for: -- Intersecting bounding boxes (which would cause overlapping text) -- Entry boxes that are too small for the specified font size - -Fix any reported errors in fields.json before proceeding. - -## Step 3: Fill the Form - -The fill script auto-detects the coordinate system and handles conversion: -`python scripts/fill_pdf_form_with_annotations.py fields.json ` - -## Step 4: Verify Output - -Convert the filled PDF to images and verify text placement: -`python scripts/convert_pdf_to_images.py ` - -If text is mispositioned: -- **Approach A**: Check that you're using PDF coordinates from form_structure.json with `pdf_width`/`pdf_height` -- **Approach B**: Check that image dimensions match and coordinates are accurate pixels -- **Hybrid**: Ensure coordinate conversions are correct for visually-estimated fields diff --git a/src/crates/assembly/core/builtin_skills/pdf/reference.md b/src/crates/assembly/core/builtin_skills/pdf/reference.md deleted file mode 100644 index 41400bf4fc..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/reference.md +++ /dev/null @@ -1,612 +0,0 @@ -# PDF Processing Advanced Reference - -This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions. - -## pypdfium2 Library (Apache/BSD License) - -### Overview -pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement. - -### Render PDF to Images -```python -import pypdfium2 as pdfium -from PIL import Image - -# Load PDF -pdf = pdfium.PdfDocument("document.pdf") - -# Render page to image -page = pdf[0] # First page -bitmap = page.render( - scale=2.0, # Higher resolution - rotation=0 # No rotation -) - -# Convert to PIL Image -img = bitmap.to_pil() -img.save("page_1.png", "PNG") - -# Process multiple pages -for i, page in enumerate(pdf): - bitmap = page.render(scale=1.5) - img = bitmap.to_pil() - img.save(f"page_{i+1}.jpg", "JPEG", quality=90) -``` - -### Extract Text with pypdfium2 -```python -import pypdfium2 as pdfium - -pdf = pdfium.PdfDocument("document.pdf") -for i, page in enumerate(pdf): - text = page.get_text() - print(f"Page {i+1} text length: {len(text)} chars") -``` - -## JavaScript Libraries - -### pdf-lib (MIT License) - -pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment. - -#### Load and Manipulate Existing PDF -```javascript -import { PDFDocument } from 'pdf-lib'; -import fs from 'fs'; - -async function manipulatePDF() { - // Load existing PDF - const existingPdfBytes = fs.readFileSync('input.pdf'); - const pdfDoc = await PDFDocument.load(existingPdfBytes); - - // Get page count - const pageCount = pdfDoc.getPageCount(); - console.log(`Document has ${pageCount} pages`); - - // Add new page - const newPage = pdfDoc.addPage([600, 400]); - newPage.drawText('Added by pdf-lib', { - x: 100, - y: 300, - size: 16 - }); - - // Save modified PDF - const pdfBytes = await pdfDoc.save(); - fs.writeFileSync('modified.pdf', pdfBytes); -} -``` - -#### Create Complex PDFs from Scratch -```javascript -import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; -import fs from 'fs'; - -async function createPDF() { - const pdfDoc = await PDFDocument.create(); - - // Add fonts - const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); - const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - - // Add page - const page = pdfDoc.addPage([595, 842]); // A4 size - const { width, height } = page.getSize(); - - // Add text with styling - page.drawText('Invoice #12345', { - x: 50, - y: height - 50, - size: 18, - font: helveticaBold, - color: rgb(0.2, 0.2, 0.8) - }); - - // Add rectangle (header background) - page.drawRectangle({ - x: 40, - y: height - 100, - width: width - 80, - height: 30, - color: rgb(0.9, 0.9, 0.9) - }); - - // Add table-like content - const items = [ - ['Item', 'Qty', 'Price', 'Total'], - ['Widget', '2', '$50', '$100'], - ['Gadget', '1', '$75', '$75'] - ]; - - let yPos = height - 150; - items.forEach(row => { - let xPos = 50; - row.forEach(cell => { - page.drawText(cell, { - x: xPos, - y: yPos, - size: 12, - font: helveticaFont - }); - xPos += 120; - }); - yPos -= 25; - }); - - const pdfBytes = await pdfDoc.save(); - fs.writeFileSync('created.pdf', pdfBytes); -} -``` - -#### Advanced Merge and Split Operations -```javascript -import { PDFDocument } from 'pdf-lib'; -import fs from 'fs'; - -async function mergePDFs() { - // Create new document - const mergedPdf = await PDFDocument.create(); - - // Load source PDFs - const pdf1Bytes = fs.readFileSync('doc1.pdf'); - const pdf2Bytes = fs.readFileSync('doc2.pdf'); - - const pdf1 = await PDFDocument.load(pdf1Bytes); - const pdf2 = await PDFDocument.load(pdf2Bytes); - - // Copy pages from first PDF - const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices()); - pdf1Pages.forEach(page => mergedPdf.addPage(page)); - - // Copy specific pages from second PDF (pages 0, 2, 4) - const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]); - pdf2Pages.forEach(page => mergedPdf.addPage(page)); - - const mergedPdfBytes = await mergedPdf.save(); - fs.writeFileSync('merged.pdf', mergedPdfBytes); -} -``` - -### pdfjs-dist (Apache License) - -PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser. - -#### Basic PDF Loading and Rendering -```javascript -import * as pdfjsLib from 'pdfjs-dist'; - -// Configure worker (important for performance) -pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js'; - -async function renderPDF() { - // Load PDF - const loadingTask = pdfjsLib.getDocument('document.pdf'); - const pdf = await loadingTask.promise; - - console.log(`Loaded PDF with ${pdf.numPages} pages`); - - // Get first page - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1.5 }); - - // Render to canvas - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - const renderContext = { - canvasContext: context, - viewport: viewport - }; - - await page.render(renderContext).promise; - document.body.appendChild(canvas); -} -``` - -#### Extract Text with Coordinates -```javascript -import * as pdfjsLib from 'pdfjs-dist'; - -async function extractText() { - const loadingTask = pdfjsLib.getDocument('document.pdf'); - const pdf = await loadingTask.promise; - - let fullText = ''; - - // Extract text from all pages - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - - const pageText = textContent.items - .map(item => item.str) - .join(' '); - - fullText += `\n--- Page ${i} ---\n${pageText}`; - - // Get text with coordinates for advanced processing - const textWithCoords = textContent.items.map(item => ({ - text: item.str, - x: item.transform[4], - y: item.transform[5], - width: item.width, - height: item.height - })); - } - - console.log(fullText); - return fullText; -} -``` - -#### Extract Annotations and Forms -```javascript -import * as pdfjsLib from 'pdfjs-dist'; - -async function extractAnnotations() { - const loadingTask = pdfjsLib.getDocument('annotated.pdf'); - const pdf = await loadingTask.promise; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const annotations = await page.getAnnotations(); - - annotations.forEach(annotation => { - console.log(`Annotation type: ${annotation.subtype}`); - console.log(`Content: ${annotation.contents}`); - console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`); - }); - } -} -``` - -## Advanced Command-Line Operations - -### poppler-utils Advanced Features - -#### Extract Text with Bounding Box Coordinates -```bash -# Extract text with bounding box coordinates (essential for structured data) -pdftotext -bbox-layout document.pdf output.xml - -# The XML output contains precise coordinates for each text element -``` - -#### Advanced Image Conversion -```bash -# Convert to PNG images with specific resolution -pdftoppm -png -r 300 document.pdf output_prefix - -# Convert specific page range with high resolution -pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages - -# Convert to JPEG with quality setting -pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output -``` - -#### Extract Embedded Images -```bash -# Extract all embedded images with metadata -pdfimages -j -p document.pdf page_images - -# List image info without extracting -pdfimages -list document.pdf - -# Extract images in their original format -pdfimages -all document.pdf images/img -``` - -### qpdf Advanced Features - -#### Complex Page Manipulation -```bash -# Split PDF into groups of pages -qpdf --split-pages=3 input.pdf output_group_%02d.pdf - -# Extract specific pages with complex ranges -qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf - -# Merge specific pages from multiple PDFs -qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf -``` - -#### PDF Optimization and Repair -```bash -# Optimize PDF for web (linearize for streaming) -qpdf --linearize input.pdf optimized.pdf - -# Remove unused objects and compress -qpdf --optimize-level=all input.pdf compressed.pdf - -# Attempt to repair corrupted PDF structure -qpdf --check input.pdf -qpdf --fix-qdf damaged.pdf repaired.pdf - -# Show detailed PDF structure for debugging -qpdf --show-all-pages input.pdf > structure.txt -``` - -#### Advanced Encryption -```bash -# Add password protection with specific permissions -qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf - -# Check encryption status -qpdf --show-encryption encrypted.pdf - -# Remove password protection (requires password) -qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf -``` - -## Advanced Python Techniques - -### pdfplumber Advanced Features - -#### Extract Text with Precise Coordinates -```python -import pdfplumber - -with pdfplumber.open("document.pdf") as pdf: - page = pdf.pages[0] - - # Extract all text with coordinates - chars = page.chars - for char in chars[:10]: # First 10 characters - print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}") - - # Extract text by bounding box (left, top, right, bottom) - bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text() -``` - -#### Advanced Table Extraction with Custom Settings -```python -import pdfplumber -import pandas as pd - -with pdfplumber.open("complex_table.pdf") as pdf: - page = pdf.pages[0] - - # Extract tables with custom settings for complex layouts - table_settings = { - "vertical_strategy": "lines", - "horizontal_strategy": "lines", - "snap_tolerance": 3, - "intersection_tolerance": 15 - } - tables = page.extract_tables(table_settings) - - # Visual debugging for table extraction - img = page.to_image(resolution=150) - img.save("debug_layout.png") -``` - -### reportlab Advanced Features - -#### Create Professional Reports with Tables -```python -from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.lib import colors - -# Sample data -data = [ - ['Product', 'Q1', 'Q2', 'Q3', 'Q4'], - ['Widgets', '120', '135', '142', '158'], - ['Gadgets', '85', '92', '98', '105'] -] - -# Create PDF with table -doc = SimpleDocTemplate("report.pdf") -elements = [] - -# Add title -styles = getSampleStyleSheet() -title = Paragraph("Quarterly Sales Report", styles['Title']) -elements.append(title) - -# Add table with advanced styling -table = Table(data) -table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), colors.grey), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), - ('ALIGN', (0, 0), (-1, -1), 'CENTER'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 14), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), colors.beige), - ('GRID', (0, 0), (-1, -1), 1, colors.black) -])) -elements.append(table) - -doc.build(elements) -``` - -## Complex Workflows - -### Extract Figures/Images from PDF - -#### Method 1: Using pdfimages (fastest) -```bash -# Extract all images with original quality -pdfimages -all document.pdf images/img -``` - -#### Method 2: Using pypdfium2 + Image Processing -```python -import pypdfium2 as pdfium -from PIL import Image -import numpy as np - -def extract_figures(pdf_path, output_dir): - pdf = pdfium.PdfDocument(pdf_path) - - for page_num, page in enumerate(pdf): - # Render high-resolution page - bitmap = page.render(scale=3.0) - img = bitmap.to_pil() - - # Convert to numpy for processing - img_array = np.array(img) - - # Simple figure detection (non-white regions) - mask = np.any(img_array != [255, 255, 255], axis=2) - - # Find contours and extract bounding boxes - # (This is simplified - real implementation would need more sophisticated detection) - - # Save detected figures - # ... implementation depends on specific needs -``` - -### Batch PDF Processing with Error Handling -```python -import os -import glob -from pypdf import PdfReader, PdfWriter -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -def batch_process_pdfs(input_dir, operation='merge'): - pdf_files = glob.glob(os.path.join(input_dir, "*.pdf")) - - if operation == 'merge': - writer = PdfWriter() - for pdf_file in pdf_files: - try: - reader = PdfReader(pdf_file) - for page in reader.pages: - writer.add_page(page) - logger.info(f"Processed: {pdf_file}") - except Exception as e: - logger.error(f"Failed to process {pdf_file}: {e}") - continue - - with open("batch_merged.pdf", "wb") as output: - writer.write(output) - - elif operation == 'extract_text': - for pdf_file in pdf_files: - try: - reader = PdfReader(pdf_file) - text = "" - for page in reader.pages: - text += page.extract_text() - - output_file = pdf_file.replace('.pdf', '.txt') - with open(output_file, 'w', encoding='utf-8') as f: - f.write(text) - logger.info(f"Extracted text from: {pdf_file}") - - except Exception as e: - logger.error(f"Failed to extract text from {pdf_file}: {e}") - continue -``` - -### Advanced PDF Cropping -```python -from pypdf import PdfWriter, PdfReader - -reader = PdfReader("input.pdf") -writer = PdfWriter() - -# Crop page (left, bottom, right, top in points) -page = reader.pages[0] -page.mediabox.left = 50 -page.mediabox.bottom = 50 -page.mediabox.right = 550 -page.mediabox.top = 750 - -writer.add_page(page) -with open("cropped.pdf", "wb") as output: - writer.write(output) -``` - -## Performance Optimization Tips - -### 1. For Large PDFs -- Use streaming approaches instead of loading entire PDF in memory -- Use `qpdf --split-pages` for splitting large files -- Process pages individually with pypdfium2 - -### 2. For Text Extraction -- `pdftotext -bbox-layout` is fastest for plain text extraction -- Use pdfplumber for structured data and tables -- Avoid `pypdf.extract_text()` for very large documents - -### 3. For Image Extraction -- `pdfimages` is much faster than rendering pages -- Use low resolution for previews, high resolution for final output - -### 4. For Form Filling -- pdf-lib maintains form structure better than most alternatives -- Pre-validate form fields before processing - -### 5. Memory Management -```python -# Process PDFs in chunks -def process_large_pdf(pdf_path, chunk_size=10): - reader = PdfReader(pdf_path) - total_pages = len(reader.pages) - - for start_idx in range(0, total_pages, chunk_size): - end_idx = min(start_idx + chunk_size, total_pages) - writer = PdfWriter() - - for i in range(start_idx, end_idx): - writer.add_page(reader.pages[i]) - - # Process chunk - with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output: - writer.write(output) -``` - -## Troubleshooting Common Issues - -### Encrypted PDFs -```python -# Handle password-protected PDFs -from pypdf import PdfReader - -try: - reader = PdfReader("encrypted.pdf") - if reader.is_encrypted: - reader.decrypt("password") -except Exception as e: - print(f"Failed to decrypt: {e}") -``` - -### Corrupted PDFs -```bash -# Use qpdf to repair -qpdf --check corrupted.pdf -qpdf --replace-input corrupted.pdf -``` - -### Text Extraction Issues -```python -# Fallback to OCR for scanned PDFs -import pytesseract -from pdf2image import convert_from_path - -def extract_text_with_ocr(pdf_path): - images = convert_from_path(pdf_path) - text = "" - for i, image in enumerate(images): - text += pytesseract.image_to_string(image) - return text -``` - -## License Information - -- **pypdf**: BSD License -- **pdfplumber**: MIT License -- **pypdfium2**: Apache/BSD License -- **reportlab**: BSD License -- **poppler-utils**: GPL-2 License -- **qpdf**: Apache License -- **pdf-lib**: MIT License -- **pdfjs-dist**: Apache License \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py deleted file mode 100644 index 2cc5e348f3..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/check_bounding_boxes.py +++ /dev/null @@ -1,65 +0,0 @@ -from dataclasses import dataclass -import json -import sys - - - - -@dataclass -class RectAndField: - rect: list[float] - rect_type: str - field: dict - - -def get_bounding_box_messages(fields_json_stream) -> list[str]: - messages = [] - fields = json.load(fields_json_stream) - messages.append(f"Read {len(fields['form_fields'])} fields") - - def rects_intersect(r1, r2): - disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0] - disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1] - return not (disjoint_horizontal or disjoint_vertical) - - rects_and_fields = [] - for f in fields["form_fields"]: - rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f)) - rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f)) - - has_error = False - for i, ri in enumerate(rects_and_fields): - for j in range(i + 1, len(rects_and_fields)): - rj = rects_and_fields[j] - if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect): - has_error = True - if ri.field is rj.field: - messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})") - else: - messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})") - if len(messages) >= 20: - messages.append("Aborting further checks; fix bounding boxes and try again") - return messages - if ri.rect_type == "entry": - if "entry_text" in ri.field: - font_size = ri.field["entry_text"].get("font_size", 14) - entry_height = ri.rect[3] - ri.rect[1] - if entry_height < font_size: - has_error = True - messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.") - if len(messages) >= 20: - messages.append("Aborting further checks; fix bounding boxes and try again") - return messages - - if not has_error: - messages.append("SUCCESS: All bounding boxes are valid") - return messages - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: check_bounding_boxes.py [fields.json]") - sys.exit(1) - with open(sys.argv[1]) as f: - messages = get_bounding_box_messages(f) - for msg in messages: - print(msg) diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py deleted file mode 100644 index 36dfb9513e..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/check_fillable_fields.py +++ /dev/null @@ -1,11 +0,0 @@ -import sys -from pypdf import PdfReader - - - - -reader = PdfReader(sys.argv[1]) -if (reader.get_fields()): - print("This PDF has fillable form fields") -else: - print("This PDF does not have fillable form fields; you will need to visually determine where to enter data") diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py deleted file mode 100644 index 7939cef56c..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import sys - -from pdf2image import convert_from_path - - - - -def convert(pdf_path, output_dir, max_dim=1000): - images = convert_from_path(pdf_path, dpi=200) - - for i, image in enumerate(images): - width, height = image.size - if width > max_dim or height > max_dim: - scale_factor = min(max_dim / width, max_dim / height) - new_width = int(width * scale_factor) - new_height = int(height * scale_factor) - image = image.resize((new_width, new_height)) - - image_path = os.path.join(output_dir, f"page_{i+1}.png") - image.save(image_path) - print(f"Saved page {i+1} as {image_path} (size: {image.size})") - - print(f"Converted {len(images)} pages to PNG images") - - -if __name__ == "__main__": - if len(sys.argv) != 3: - print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") - sys.exit(1) - pdf_path = sys.argv[1] - output_directory = sys.argv[2] - convert(pdf_path, output_directory) diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py deleted file mode 100644 index 10eadd8124..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/create_validation_image.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -import sys - -from PIL import Image, ImageDraw - - - - -def create_validation_image(page_number, fields_json_path, input_path, output_path): - with open(fields_json_path, 'r') as f: - data = json.load(f) - - img = Image.open(input_path) - draw = ImageDraw.Draw(img) - num_boxes = 0 - - for field in data["form_fields"]: - if field["page_number"] == page_number: - entry_box = field['entry_bounding_box'] - label_box = field['label_bounding_box'] - draw.rectangle(entry_box, outline='red', width=2) - draw.rectangle(label_box, outline='blue', width=2) - num_boxes += 2 - - img.save(output_path) - print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") - - -if __name__ == "__main__": - if len(sys.argv) != 5: - print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]") - sys.exit(1) - page_number = int(sys.argv[1]) - fields_json_path = sys.argv[2] - input_image_path = sys.argv[3] - output_image_path = sys.argv[4] - create_validation_image(page_number, fields_json_path, input_image_path, output_image_path) diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py deleted file mode 100644 index 64cd4703a4..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_field_info.py +++ /dev/null @@ -1,122 +0,0 @@ -import json -import sys - -from pypdf import PdfReader - - - - -def get_full_annotation_field_id(annotation): - components = [] - while annotation: - field_name = annotation.get('/T') - if field_name: - components.append(field_name) - annotation = annotation.get('/Parent') - return ".".join(reversed(components)) if components else None - - -def make_field_dict(field, field_id): - field_dict = {"field_id": field_id} - ft = field.get('/FT') - if ft == "/Tx": - field_dict["type"] = "text" - elif ft == "/Btn": - field_dict["type"] = "checkbox" - states = field.get("/_States_", []) - if len(states) == 2: - if "/Off" in states: - field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] - field_dict["unchecked_value"] = "/Off" - else: - print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.") - field_dict["checked_value"] = states[0] - field_dict["unchecked_value"] = states[1] - elif ft == "/Ch": - field_dict["type"] = "choice" - states = field.get("/_States_", []) - field_dict["choice_options"] = [{ - "value": state[0], - "text": state[1], - } for state in states] - else: - field_dict["type"] = f"unknown ({ft})" - return field_dict - - -def get_field_info(reader: PdfReader): - fields = reader.get_fields() - - field_info_by_id = {} - possible_radio_names = set() - - for field_id, field in fields.items(): - if field.get("/Kids"): - if field.get("/FT") == "/Btn": - possible_radio_names.add(field_id) - continue - field_info_by_id[field_id] = make_field_dict(field, field_id) - - - radio_fields_by_id = {} - - for page_index, page in enumerate(reader.pages): - annotations = page.get('/Annots', []) - for ann in annotations: - field_id = get_full_annotation_field_id(ann) - if field_id in field_info_by_id: - field_info_by_id[field_id]["page"] = page_index + 1 - field_info_by_id[field_id]["rect"] = ann.get('/Rect') - elif field_id in possible_radio_names: - try: - on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] - except KeyError: - continue - if len(on_values) == 1: - rect = ann.get("/Rect") - if field_id not in radio_fields_by_id: - radio_fields_by_id[field_id] = { - "field_id": field_id, - "type": "radio_group", - "page": page_index + 1, - "radio_options": [], - } - radio_fields_by_id[field_id]["radio_options"].append({ - "value": on_values[0], - "rect": rect, - }) - - fields_with_location = [] - for field_info in field_info_by_id.values(): - if "page" in field_info: - fields_with_location.append(field_info) - else: - print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") - - def sort_key(f): - if "radio_options" in f: - rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] - else: - rect = f.get("rect") or [0, 0, 0, 0] - adjusted_position = [-rect[1], rect[0]] - return [f.get("page"), adjusted_position] - - sorted_fields = fields_with_location + list(radio_fields_by_id.values()) - sorted_fields.sort(key=sort_key) - - return sorted_fields - - -def write_field_info(pdf_path: str, json_output_path: str): - reader = PdfReader(pdf_path) - field_info = get_field_info(reader) - with open(json_output_path, "w") as f: - json.dump(field_info, f, indent=2) - print(f"Wrote {len(field_info)} fields to {json_output_path}") - - -if __name__ == "__main__": - if len(sys.argv) != 3: - print("Usage: extract_form_field_info.py [input pdf] [output json]") - sys.exit(1) - write_field_info(sys.argv[1], sys.argv[2]) diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py deleted file mode 100755 index f219e7d5b5..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/extract_form_structure.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Extract form structure from a non-fillable PDF. - -This script analyzes the PDF to find: -- Text labels with their exact coordinates -- Horizontal lines (row boundaries) -- Checkboxes (small rectangles) - -Output: A JSON file with the form structure that can be used to generate -accurate field coordinates for filling. - -Usage: python extract_form_structure.py -""" - -import json -import sys -import pdfplumber - - -def extract_form_structure(pdf_path): - structure = { - "pages": [], - "labels": [], - "lines": [], - "checkboxes": [], - "row_boundaries": [] - } - - with pdfplumber.open(pdf_path) as pdf: - for page_num, page in enumerate(pdf.pages, 1): - structure["pages"].append({ - "page_number": page_num, - "width": float(page.width), - "height": float(page.height) - }) - - words = page.extract_words() - for word in words: - structure["labels"].append({ - "page": page_num, - "text": word["text"], - "x0": round(float(word["x0"]), 1), - "top": round(float(word["top"]), 1), - "x1": round(float(word["x1"]), 1), - "bottom": round(float(word["bottom"]), 1) - }) - - for line in page.lines: - if abs(float(line["x1"]) - float(line["x0"])) > page.width * 0.5: - structure["lines"].append({ - "page": page_num, - "y": round(float(line["top"]), 1), - "x0": round(float(line["x0"]), 1), - "x1": round(float(line["x1"]), 1) - }) - - for rect in page.rects: - width = float(rect["x1"]) - float(rect["x0"]) - height = float(rect["bottom"]) - float(rect["top"]) - if 5 <= width <= 15 and 5 <= height <= 15 and abs(width - height) < 2: - structure["checkboxes"].append({ - "page": page_num, - "x0": round(float(rect["x0"]), 1), - "top": round(float(rect["top"]), 1), - "x1": round(float(rect["x1"]), 1), - "bottom": round(float(rect["bottom"]), 1), - "center_x": round((float(rect["x0"]) + float(rect["x1"])) / 2, 1), - "center_y": round((float(rect["top"]) + float(rect["bottom"])) / 2, 1) - }) - - lines_by_page = {} - for line in structure["lines"]: - page = line["page"] - if page not in lines_by_page: - lines_by_page[page] = [] - lines_by_page[page].append(line["y"]) - - for page, y_coords in lines_by_page.items(): - y_coords = sorted(set(y_coords)) - for i in range(len(y_coords) - 1): - structure["row_boundaries"].append({ - "page": page, - "row_top": y_coords[i], - "row_bottom": y_coords[i + 1], - "row_height": round(y_coords[i + 1] - y_coords[i], 1) - }) - - return structure - - -def main(): - if len(sys.argv) != 3: - print("Usage: extract_form_structure.py ") - sys.exit(1) - - pdf_path = sys.argv[1] - output_path = sys.argv[2] - - print(f"Extracting structure from {pdf_path}...") - structure = extract_form_structure(pdf_path) - - with open(output_path, "w") as f: - json.dump(structure, f, indent=2) - - print(f"Found:") - print(f" - {len(structure['pages'])} pages") - print(f" - {len(structure['labels'])} text labels") - print(f" - {len(structure['lines'])} horizontal lines") - print(f" - {len(structure['checkboxes'])} checkboxes") - print(f" - {len(structure['row_boundaries'])} row boundaries") - print(f"Saved to {output_path}") - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py deleted file mode 100644 index 51c2600f38..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_fillable_fields.py +++ /dev/null @@ -1,98 +0,0 @@ -import json -import sys - -from pypdf import PdfReader, PdfWriter - -from extract_form_field_info import get_field_info - - - - -def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str): - with open(fields_json_path) as f: - fields = json.load(f) - fields_by_page = {} - for field in fields: - if "value" in field: - field_id = field["field_id"] - page = field["page"] - if page not in fields_by_page: - fields_by_page[page] = {} - fields_by_page[page][field_id] = field["value"] - - reader = PdfReader(input_pdf_path) - - has_error = False - field_info = get_field_info(reader) - fields_by_ids = {f["field_id"]: f for f in field_info} - for field in fields: - existing_field = fields_by_ids.get(field["field_id"]) - if not existing_field: - has_error = True - print(f"ERROR: `{field['field_id']}` is not a valid field ID") - elif field["page"] != existing_field["page"]: - has_error = True - print(f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})") - else: - if "value" in field: - err = validation_error_for_field_value(existing_field, field["value"]) - if err: - print(err) - has_error = True - if has_error: - sys.exit(1) - - writer = PdfWriter(clone_from=reader) - for page, field_values in fields_by_page.items(): - writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False) - - writer.set_need_appearances_writer(True) - - with open(output_pdf_path, "wb") as f: - writer.write(f) - - -def validation_error_for_field_value(field_info, field_value): - field_type = field_info["type"] - field_id = field_info["field_id"] - if field_type == "checkbox": - checked_val = field_info["checked_value"] - unchecked_val = field_info["unchecked_value"] - if field_value != checked_val and field_value != unchecked_val: - return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"' - elif field_type == "radio_group": - option_values = [opt["value"] for opt in field_info["radio_options"]] - if field_value not in option_values: - return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}' - elif field_type == "choice": - choice_values = [opt["value"] for opt in field_info["choice_options"]] - if field_value not in choice_values: - return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}' - return None - - -def monkeypatch_pydpf_method(): - from pypdf.generic import DictionaryObject - from pypdf.constants import FieldDictionaryAttributes - - original_get_inherited = DictionaryObject.get_inherited - - def patched_get_inherited(self, key: str, default = None): - result = original_get_inherited(self, key, default) - if key == FieldDictionaryAttributes.Opt: - if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result): - result = [r[0] for r in result] - return result - - DictionaryObject.get_inherited = patched_get_inherited - - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]") - sys.exit(1) - monkeypatch_pydpf_method() - input_pdf = sys.argv[1] - fields_json = sys.argv[2] - output_pdf = sys.argv[3] - fill_pdf_fields(input_pdf, fields_json, output_pdf) diff --git a/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py b/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py deleted file mode 100644 index b430069fd0..0000000000 --- a/src/crates/assembly/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py +++ /dev/null @@ -1,107 +0,0 @@ -import json -import sys - -from pypdf import PdfReader, PdfWriter -from pypdf.annotations import FreeText - - - - -def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_height): - x_scale = pdf_width / image_width - y_scale = pdf_height / image_height - - left = bbox[0] * x_scale - right = bbox[2] * x_scale - - top = pdf_height - (bbox[1] * y_scale) - bottom = pdf_height - (bbox[3] * y_scale) - - return left, bottom, right, top - - -def transform_from_pdf_coords(bbox, pdf_height): - left = bbox[0] - right = bbox[2] - - pypdf_top = pdf_height - bbox[1] - pypdf_bottom = pdf_height - bbox[3] - - return left, pypdf_bottom, right, pypdf_top - - -def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): - - with open(fields_json_path, "r") as f: - fields_data = json.load(f) - - reader = PdfReader(input_pdf_path) - writer = PdfWriter() - - writer.append(reader) - - pdf_dimensions = {} - for i, page in enumerate(reader.pages): - mediabox = page.mediabox - pdf_dimensions[i + 1] = [mediabox.width, mediabox.height] - - annotations = [] - for field in fields_data["form_fields"]: - page_num = field["page_number"] - - page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num) - pdf_width, pdf_height = pdf_dimensions[page_num] - - if "pdf_width" in page_info: - transformed_entry_box = transform_from_pdf_coords( - field["entry_bounding_box"], - float(pdf_height) - ) - else: - image_width = page_info["image_width"] - image_height = page_info["image_height"] - transformed_entry_box = transform_from_image_coords( - field["entry_bounding_box"], - image_width, image_height, - float(pdf_width), float(pdf_height) - ) - - if "entry_text" not in field or "text" not in field["entry_text"]: - continue - entry_text = field["entry_text"] - text = entry_text["text"] - if not text: - continue - - font_name = entry_text.get("font", "Arial") - font_size = str(entry_text.get("font_size", 14)) + "pt" - font_color = entry_text.get("font_color", "000000") - - annotation = FreeText( - text=text, - rect=transformed_entry_box, - font=font_name, - font_size=font_size, - font_color=font_color, - border_color=None, - background_color=None, - ) - annotations.append(annotation) - writer.add_annotation(page_number=page_num - 1, annotation=annotation) - - with open(output_pdf_path, "wb") as output: - writer.write(output) - - print(f"Successfully filled PDF form and saved to {output_pdf_path}") - print(f"Added {len(annotations)} text annotations") - - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]") - sys.exit(1) - input_pdf = sys.argv[1] - fields_json = sys.argv[2] - output_pdf = sys.argv[3] - - fill_pdf_form(input_pdf, fields_json, output_pdf) diff --git a/src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt deleted file mode 100644 index c55ab42224..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -© 2025 Anthropic, PBC. All rights reserved. - -LICENSE: Use of these materials (including all code, prompts, assets, files, -and other components of this Skill) is governed by your agreement with -Anthropic regarding use of Anthropic's services. If no separate agreement -exists, use is governed by Anthropic's Consumer Terms of Service or -Commercial Terms of Service, as applicable: -https://www.anthropic.com/legal/consumer-terms -https://www.anthropic.com/legal/commercial-terms -Your applicable agreement is referred to as the "Agreement." "Services" are -as defined in the Agreement. - -ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the -contrary, users may not: - -- Extract these materials from the Services or retain copies of these - materials outside the Services -- Reproduce or copy these materials, except for temporary copies created - automatically during authorized use of the Services -- Create derivative works based on these materials -- Distribute, sublicense, or transfer these materials to any third party -- Make, offer to sell, sell, or import any inventions embodied in these - materials -- Reverse engineer, decompile, or disassemble these materials - -The receipt, viewing, or possession of these materials does not convey or -imply any license or right beyond those expressly granted above. - -Anthropic retains all right, title, and interest in these materials, -including all copyrights, patents, and other intellectual property rights. diff --git a/src/crates/assembly/core/builtin_skills/pptx/SKILL.md b/src/crates/assembly/core/builtin_skills/pptx/SKILL.md deleted file mode 100644 index 4a72b9bc18..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/SKILL.md +++ /dev/null @@ -1,238 +0,0 @@ ---- -name: pptx -description: "Use this skill any time a .pptx or .potx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx or .potx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates (.potx), layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx or .potx filename, regardless of what they plan to do with the content afterward. If a .pptx or .potx file needs to be opened, created, or touched, use this skill." -license: Proprietary. LICENSE.txt has complete terms ---- - -# PPTX creation, editing, and analysis - -A `.pptx` is a ZIP archive of XML files. Choose your approach by task: - -| Task | Approach | -|---|---| -| **Create** a new deck | Write a `pptxgenjs` script — see gotchas below | -| **Edit** an existing deck, or build from a template | `safe_extract` → edit `ppt/slides/slideN.xml` → `rezip` | -| **Read** content | `markitdown deck.pptx` (one block per slide under `` markers); visual grid: `python scripts/thumbnail.py deck.pptx` | - -## Scripts - -Paths are relative to this skill's directory. Everything else is plain Python, `node`, or shell. - -| Script | What it does | -|---|---| -| `scripts/thumbnail.py deck.pptx [prefix]` | Labeled grid of every slide, for picking template layouts. `.pptx` only. Pass `prefix` — it defaults to `thumbnails`, which overwrites the grids of any other deck done in the same directory | -| `scripts/add_slide.py unpacked/ slide2.xml [--after slideN.xml]` | Duplicate a slide (or a `slideLayoutN.xml`) with all the package bookkeeping. Also takes a `.pptx` directly with `-o out.pptx` | -| `scripts/clean.py unpacked/` | Delete slides, media, and rels no longer referenced. Run **after** `` is final | -| `scripts/office/validate.py deck.pptx [--original src.pptx]` | Schema, relationship, content-type, chart and slide checks; each failure names its fix. Pass `--original` for any template-derived deck — it baselines the schema checks against the template, so the template's own XSD errors don't read as yours | -| `scripts/office/soffice.py --headless --convert-to pdf deck.pptx` | LibreOffice wrapper — bare `soffice` hangs in this sandbox | - -## Creating with pptxgenjs — gotchas - -`pptxgenjs` is preinstalled — do not run `npm install` first; write the script and `require('pptxgenjs')` directly. Only if that require fails: `npm install pptxgenjs`. The model knows the API; these are the footguns: - -- **Set `pres.layout` before adding slides.** The default canvas is `LAYOUT_16x9` = **10" × 5.625"**, not 13.3" wide. Coordinates past the edge are written, not clamped — the shape just isn't on the slide. (`LAYOUT_WIDE` is 13.3" × 7.5".) -- **Hex colors: never `#`, never 8 digits.** `color: "FF0000"`. Both `"#FF0000"` and alpha baked into the hex (`"00000020"`) **corrupt the file**. For translucency: `transparency: 0-100` on fills and images, `opacity: 0.0-1.0` on shadows — each is silently ignored on the other. -- **pptxgenjs mutates option objects in place** (converts values to EMU on first use). Never share one `shadow`/options object across two `add*` calls — build a fresh object each time. -- **Shadow `offset` must be ≥ 0** — a negative offset corrupts the file. To cast a shadow upward, use `angle: 270` with a positive offset. -- **`letterSpacing` is silently ignored** — the real option is `charSpacing`. -- **Lists:** `bullet: true` on each item, never a literal `•` (renders double bullets). Set `breakLine: true` on every array item except the last. Space bulleted paragraphs with `paraSpaceAfter`, not `lineSpacing` (huge gaps). -- **One `new pptxgen()` per output file** — never reuse an instance. -- **`rectRadius` only works on `ROUNDED_RECTANGLE`**, not `RECTANGLE`. -- **Gradient fills aren't supported** — use a gradient image as the background instead. -- **Text boxes have built-in internal padding** — set `margin: 0` whenever text must align with a shape, line, or icon at the same x. -- **Speaker notes go in `slide.addNotes("...")`** (plain text, once per slide), never in a text box on the slide. -- **Keep charts native.** Use `addChart()` for everything PowerPoint can chart (pass an array of `{type, data, options}` for combos). For PowerPoint-native features the library doesn't expose (trendlines, error bars), compute the extra series yourself or post-process the generated OOXML — do not fall back to a rendered image. Only chart types PowerPoint has no native form for (Sankey, network, chord) go in as images. -- **Default charts render bare** — no title, no data labels, dated palette. Set `showTitle` + `title`, `showValue: true` + `dataLabelPosition`, `chartColors: [...]` from your palette, and quiet the frame (`catAxisLabelColor`/`valAxisLabelColor`, `valGridLine: { color, size }`, `catGridLine: { style: "none" }`, `showLegend: false` for a single series). -- **On a stacked bar or column chart, `dataLabelPosition` must be `ctr`, `inEnd`, or `inBase`.** `outEnd` **corrupts the file**. -- **A combo series using `secondaryValAxis`/`secondaryCatAxis` needs both `valAxes` and `catAxes` on the chart options, two entries each.** Without them pptxgenjs writes axis *ids* it never declares, and PowerPoint **discards that chart** and reports the file as corrupt. Supplying only `valAxes` is not enough. -- **After `writeFile()`, run `python scripts/office/validate.py deck.pptx`.** It reports the two chart faults above and the slide-XML defects PowerPoint refuses, and names the fix for each. Fix them in your generator, not by hand-editing the packed XML. -- **Never reorder the children of ``.** pptxgenjs writes `` right after `` and points both masters at one theme part. PowerPoint reads that happily — move the element and the same deck becomes unopenable. -- **Icons:** render `react-icons` to SVG (`ReactDOMServer.renderToStaticMarkup`), rasterize with `sharp` at ≥256px, and insert via `addImage({ data: "image/png;base64," + buf.toString("base64") })` — the `image/png;base64,` prefix is required (`react-icons`, `react`, `react-dom`, and `sharp` are preinstalled — `npm install react-icons react react-dom sharp` only if a require fails). - -## Editing existing decks and templates - -Pick layouts first: `python scripts/thumbnail.py template.pptx template-thumbs` writes a labeled grid of every slide and prints the file(s) it created — `template-thumbs.jpg`, split into `template-thumbs-N.jpg` past 12 slides. **Always pass that second argument, named after the deck.** It defaults to `thumbnails`, so two decks thumbnailed in one directory silently overwrite each other's grids — the first deck's are simply gone (template analysis only — visual QA needs the full-resolution renders from [Converting to Images](#converting-to-images); it only accepts `.pptx`, so copy a `.potx` to a `.pptx` name first). Use it with `markitdown` to map each content section onto a template slide, and vary the layouts — don't put every section on the same title-and-bullets slide. - -```bash -python -c "import sys,zipfile; from pathlib import Path; from scripts.office.helpers import safe_extract; zf=zipfile.ZipFile(sys.argv[1]); safe_extract(zf, Path('unpacked')); zf.close()" deck.pptx -python scripts/add_slide.py unpacked/ slide2.xml --after slide2.xml # duplicate a slide (or slideLayoutN.xml); prints the new slide's path -# reorder / delete slides = edit in ppt/presentation.xml -python scripts/clean.py unpacked/ # after deletions: removes orphaned slides, media, rels -# edit slide content in ppt/slides/slideN.xml -python -c "from pathlib import Path; from scripts.office.helpers import rezip; rezip(Path('unpacked'), Path('out.pptx'))" -python scripts/office/validate.py out.pptx --original deck.pptx -``` - -- **Do all structural work — add, delete, reorder — before editing any slide's content.** `add_slide.py` copies a slide file verbatim, so duplicating after you edit clones the edited content; and `clean.py` deletes any slide missing from ``, including one you just wrote. -- **Never copy a slide file by hand** — `add_slide.py` does every registration a new slide needs and reports what it made (`Created ppt/slides/slide17.xml from slide2.xml`). It also works directly on a file: `add_slide.py deck.pptx slide2.xml -o out.pptx` — **pass `-o`, or it rewrites the input deck in place.** A duplicated slide still *references* its source's chart/SmartArt/embedded-object parts rather than cloning them, so editing one slide's chart changes the other's. -- **If you use `python-pptx`**, three things it won't do: duplicate a slide (its only entry point is `add_slide(layout)`), preserve formatting through `text_frame.text = "..."` (that collapses the paragraph to a single unstyled run — assign `run.text` instead), or read the SVG/EMF most template art uses (`add_picture` raises `UnidentifiedImageError`). -- Legacy `.ppt` must be converted first: `python scripts/office/soffice.py --headless --convert-to pptx file.ppt`. `.potx` templates unpack and pack identically — keep the `.potx` extension on the output. -- To reuse a template icon or image, duplicate a slide or layout that already contains it. - -When filling in a template: - -- If you script an XML transform, parse with `defusedxml.minidom` — round-tripping OOXML through `xml.etree.ElementTree` rewrites namespace prefixes and corrupts the deck. -- **Template slots ≠ source items.** If the template shows 4 team members and you have 3, delete the 4th member's entire group (image + text boxes), not just its text — then check for orphaned visuals in QA. -- One `` per list item — never concatenate items into a single paragraph. Copy the sibling `` to preserve spacing, and put `b="1"` on the `` of titles, section headers, and inline labels (`Status:`, `Owner:`). -- Let bullets inherit from the layout; only add ``, `` (numbered), or `` to override — never a literal `•` in the text. -- Text with leading or trailing spaces needs `xml:space="preserve"` on its ``. - -## Design Ideas - -**Don't create boring slides.** Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide. - -### Before Starting - -- **Pick a bold, content-informed color palette**: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices. -- **Dominance over equality**: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight. -- **Dark/light contrast**: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel. -- **Commit to a visual motif**: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles. Carry it across every slide. **Do not use a color bar or accent stripe as your motif** (see Avoid list). - -### Color Palettes - -Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration: - -| Theme | Primary | Secondary | Accent | -|-------|---------|-----------|--------| -| **Midnight Executive** | `1E2761` (navy) | `CADCFC` (ice blue) | `FFFFFF` (white) | -| **Forest & Moss** | `2C5F2D` (forest) | `97BC62` (moss) | `F5F5F5` (cream) | -| **Coral Energy** | `F96167` (coral) | `F9E795` (gold) | `2F3C7E` (navy) | -| **Warm Terracotta** | `B85042` (terracotta) | `E7E8D1` (sand) | `A7BEAE` (sage) | -| **Ocean Gradient** | `065A82` (deep blue) | `1C7293` (teal) | `21295C` (midnight) | -| **Charcoal Minimal** | `36454F` (charcoal) | `F2F2F2` (off-white) | `212121` (black) | -| **Teal Trust** | `028090` (teal) | `00A896` (seafoam) | `02C39A` (mint) | -| **Berry & Cream** | `6D2E46` (berry) | `A26769` (dusty rose) | `ECE2D0` (cream) | -| **Sage Calm** | `84B59F` (sage) | `69A297` (eucalyptus) | `50808E` (slate) | -| **Cherry Bold** | `990011` (cherry) | `FCF6F5` (off-white) | `2F3C7E` (navy) | - -### For Each Slide - -**Every slide needs a visual element** — image, chart, icon, or shape. Text-only slides are forgettable. - -**Layout options:** -- Two-column (text left, illustration on right) -- Icon + text rows (icon in colored circle, bold header, description below) -- 2x2 or 2x3 grid (image on one side, grid of content blocks on other) -- Half-bleed image (full left or right side) with content overlay - -**Data display:** -- Large stat callouts (big numbers 60-72pt with small labels below) -- Comparison columns (before/after, pros/cons, side-by-side options) -- Timeline or process flow (numbered steps, arrows) - -**Visual polish:** -- Icons in small colored circles next to section headers -- Italic accent text for key stats or taglines - -### Typography - -**Font names you write into the .pptx are rendered by the user's PowerPoint, not by this environment.** Your visual QA renders via LibreOffice, which substitutes fonts it doesn't have — and for some fonts the substitute has different widths, so your QA preview can show text overflow (or fit) that the real deck won't have. To keep your QA trustworthy: - -- **Safe fonts** (render true-to-width in QA *and* ship with Office): **Arial, Calibri, Cambria, Times New Roman, Courier New, Bookman Old Style, Century Schoolbook**. Use these for body text and anything where fit matters. -- **Headers with personality at zero QA risk**: pair a safe-list serif header (Cambria, Bookman Old Style, Century Schoolbook) with a safe-list sans body (Calibri or Arial). You get visual contrast without giving up reliable overflow checks. -- **If the user asks for a font outside the safe list** (e.g. Georgia or Trebuchet MS): use it where the user asked, but size those containers with extra slack (~10%) and don't trust QA text-fit on those elements — the preview of that font is approximate. If the user hasn't specified, prefer safe-list fonts for body text. -- **QA-unreliable fonts** (substitute has different widths — overflow checks can be wrong): Georgia, Trebuchet MS, Impact, Arial Black, Garamond, Consolas, Palatino Linotype. Calibri Light substitution varies by environment; treat as QA-unreliable. Fine for titles/accents with slack; don't trust QA text-fit on these. -- **Never default to Aptos** — Office's post-2023 default has no metric-compatible substitute here *and* is missing from older Office installs, so it's unreliable on both ends. - -| Element | Size | -|---------|------| -| Slide title | 36-44pt bold | -| Section header | 20-24pt bold | -| Body text | 14-16pt | -| Captions | 10-12pt muted | - -### Spacing - -- 0.5" minimum margins -- 0.3-0.5" between content blocks -- Leave breathing room—don't fill every inch - -### Avoid (Common Mistakes) - -- **Don't repeat the same layout** — vary columns, cards, and callouts across slides -- **Don't center body text** — left-align paragraphs and lists; center only titles -- **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body -- **Don't default to blue** — pick colors that reflect the specific topic -- **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently -- **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout -- **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets -- **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding -- **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds -- **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead -- **NEVER add decorative color bars or accent stripes** — this includes: header/footer bars spanning the slide width, vertical sidebar stripes down one edge of the slide, thin accent stripes along one edge of a card or content block, and "single-side borders" on rectangles. These read as AI-generated filler. If you want to set a card apart, use a subtle background tint, a drop shadow, or an icon — not an edge stripe. -- **Don't default to cream/beige backgrounds** — when no background is specified, use white (`FFFFFF`) or the user's brand palette; avoid warm-neutral defaults like `F5F5DC`, `FAF0E6`, `FAEBD7`, `FFF8E1` -- **Don't ship text that overflows its shape** — if text doesn't fit, reduce font size, split across slides, or enlarge the container; never leave content cut off or spilling past bounds - -## QA (Required) - -Your first render usually has a few real issues — overlaps, overflow, misalignment. Find and fix those, re-render only the slides you changed, and stop. - -### Content QA - -```bash -markitdown output.pptx -``` - -Check for missing content, typos, wrong order. - -**When using templates, check for leftover placeholder text:** - -```bash -markitdown output.pptx | grep -iE "\bx{3,}\b|lorem|ipsum|\bTODO|\[insert|this.*(page|slide).*layout" -``` - -If grep returns results, fix them before declaring success. - -### File QA (required) - -```bash -python scripts/office/validate.py output.pptx # built from scratch -python scripts/office/validate.py output.pptx --original src.pptx # built from a template -``` - -**If the deck came from a template, always pass `--original`.** A template may itself -contain parts the XSD rejects, so a bare run can report failures you never caused — and -a genuine regression can hide among them. `--original` baselines -the schema and slide checks against the template, suppressing errors it already had. -The structural checks — relationships, content types, charts — ignore `--original` and -report template-inherited problems either way, so read those on their own merits. - -pptxgenjs emits chart XML PowerPoint refuses to open, and every other tool -accepts: python-pptx opens those decks, LibreOffice renders them, the XSD -passes them. Every failure names its fix. Fix it in the generator and rebuild. - -### Visual QA - -Convert the slides to images (see [Converting to Images](#converting-to-images)) and inspect every one. After staring at the generating code you tend to see what you expect rather than what rendered, so look at the images fresh (a subagent works well for this if you have one). User-visible defects to look for: - -- **Text overflow or text cut off at a box or slide boundary — check this first.** It is the most common defect and always user-visible. (For a font the previewer renders unreliably per Typography, the preview is approximate: trust the ~10% slack you left, not its apparent fit.) -- Overlapping elements (text through shapes, lines through words, stacked elements) -- Source citations or footers colliding with content above -- Elements too close (< 0.3" gaps) or cards/sections nearly touching -- Uneven gaps (large empty area in one place, cramped in another) -- Insufficient margin from slide edges (< 0.5") -- Columns or similar elements not aligned consistently -- Low-contrast text (e.g., light gray text on cream-colored background) -- Template decoration mispositioned after text replacement — e.g., a title underline positioned for one line, but the replaced title wrapped to two -- Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle) -- Text boxes too narrow causing excessive wrapping -- Leftover placeholder content - -## Converting to Images - -Convert presentations to individual slide images for visual inspection: - -```bash -python scripts/office/soffice.py --headless --convert-to pdf output.pptx -rm -f slide-*.jpg -pdftoppm -jpeg -r 150 output.pdf slide -ls -1 "$PWD"/slide-*.jpg -``` - -**Pass the absolute paths printed above directly to the view tool.** The `rm` clears stale images from prior runs. `pdftoppm` zero-pads based on page count: `slide-1.jpg` for decks under 10 pages, `slide-01.jpg` for 10-99, `slide-001.jpg` for 100+. - -**After fixes, rerun all four commands above** — the PDF must be regenerated from the edited `.pptx` before `pdftoppm` can reflect your changes. - -## Dependencies - -`pptxgenjs` (npm, preinstalled — install only if `require('pptxgenjs')` fails) · `markitdown[pptx]`, `Pillow`, `defusedxml`, `lxml` (pip — text dump, thumbnail, clean, validate) · LibreOffice (`soffice`, auto-configured for sandboxed environments via `scripts/office/soffice.py`) · `pdftoppm` (Poppler) diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py deleted file mode 100755 index f013ea94d1..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/add_slide.py +++ /dev/null @@ -1,367 +0,0 @@ -"""Add a slide to a PPTX: duplicate an existing slide or instantiate a layout. - -Does all of the package bookkeeping, so the deck stays valid: - - writes the new ppt/slides/slideN.xml (and its .rels, minus any - notesSlide reference, so the source's speaker notes aren't shared) - - registers it in [Content_Types].xml - - adds a slide relationship with a fresh rId to presentation.xml.rels - - inserts with a fresh id into - — at the end, or after --after SLIDE - -Works on an unpacked directory (during an editing session) or directly on a -.pptx/.potx file (extracted to a temp dir, then rezipped atomically; the -temp dir is discarded, so unpack the output if you still need to edit the -new slide's content). - -Usage: - python add_slide.py unpacked/ slide2.xml # duplicate slide2 - python add_slide.py unpacked/ slideLayout3.xml # new slide from a layout - python add_slide.py unpacked/ slide2.xml --after slide2.xml - python add_slide.py deck.pptx slide2.xml # rewrite deck.pptx in place - python add_slide.py deck.pptx slide2.xml -o out.pptx - -A duplicated slide still holds the source's content: edit ppt/slides/slideN.xml -(printed on success) to change it. To list layouts: ls

/ppt/slideLayouts/ -""" - -import argparse -import re -import shutil -import sys -from typing import NoReturn -import tempfile -import zipfile -from pathlib import Path - -from office.helpers import rezip, safe_extract - -MINIMAL_SLIDE_XML = ''' - - - - - - - - - - - - - - - - - - - - - -''' - -SHARED_PART_TYPES = ("chart", "diagramData", "oleObject", "package") - -NOTES_SLIDE_TYPE_RE = re.compile(r"""Type=["'][^"']*/relationships/notesSlide["']""") -RELATIONSHIP_RE = re.compile(r"]*?(?:/>|>.*?)", re.DOTALL) - -SLIDE_ID_MIN = 256 -SLIDE_ID_MAX = 2147483647 - - -def _die(msg: str) -> NoReturn: - print(f"Error: {msg}", file=sys.stderr) - sys.exit(1) - - -def get_next_slide_number(slides_dir: Path) -> int: - existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml") - if (m := re.match(r"slide(\d+)\.xml", f.name))] - return max(existing) + 1 if existing else 1 - - -def parse_source(source: str) -> tuple[str, str | None]: - if source.startswith("slideLayout") and source.endswith(".xml"): - return ("layout", source) - - return ("slide", None) - - -def create_slide_from_layout(unpacked_dir: Path, layout_file: str, after: str | None = None) -> str: - slides_dir = unpacked_dir / "ppt" / "slides" - rels_dir = slides_dir / "_rels" - layout_path = unpacked_dir / "ppt" / "slideLayouts" / layout_file - - if not layout_path.exists(): - _die(f"{layout_path} not found") - - next_num = get_next_slide_number(slides_dir) - dest = f"slide{next_num}.xml" - after_rid = _precheck_registration(unpacked_dir, after, dest) - slides_dir.mkdir(parents=True, exist_ok=True) - - (slides_dir / dest).write_text(MINIMAL_SLIDE_XML, encoding="utf-8") - - rels_dir.mkdir(exist_ok=True) - rels_xml = f''' - - -''' - (rels_dir / f"{dest}.rels").write_text(rels_xml, encoding="utf-8") - - _register_slide(unpacked_dir, dest, layout_file, after_rid) - return dest - - -def duplicate_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: - slides_dir = unpacked_dir / "ppt" / "slides" - rels_dir = slides_dir / "_rels" - source_slide = slides_dir / source - - if not source_slide.exists(): - _die(f"{source_slide} not found") - - next_num = get_next_slide_number(slides_dir) - dest = f"slide{next_num}.xml" - after_rid = _precheck_registration(unpacked_dir, after, dest) - - shutil.copy2(source_slide, slides_dir / dest) - - source_rels = rels_dir / f"{source}.rels" - shared_parts: list[str] = [] - if source_rels.exists(): - dest_rels = rels_dir / f"{dest}.rels" - shutil.copy2(source_rels, dest_rels) - rels_content = dest_rels.read_text(encoding="utf-8") - rels_content = RELATIONSHIP_RE.sub( - lambda m: "" if NOTES_SLIDE_TYPE_RE.search(m.group(0)) else m.group(0), - rels_content, - ) - dest_rels.write_text(rels_content, encoding="utf-8") - shared_parts = sorted({ - t for t in re.findall(r'Type="[^"]*/relationships/(\w+)"', rels_content) - if t in SHARED_PART_TYPES - }) - - _register_slide(unpacked_dir, dest, source, after_rid) - if shared_parts: - print( - f"Note: {dest} shares its {', '.join(shared_parts)} part(s) with {source} " - f"(they are referenced, not copied) — editing those parts changes both slides" - ) - return dest - - -def _precheck_registration(unpacked_dir: Path, after: str | None, dest: str) -> str | None: - pres_path = unpacked_dir / "ppt" / "presentation.xml" - if not pres_path.exists(): - _die(f"{pres_path} not found — is this an unpacked PPTX?") - xml = pres_path.read_text(encoding="utf-8") - - has_slot = ( - "" in xml - or re.search(r"", xml) - or "" in xml - ) - if not has_slot: - _die("presentation.xml has no (or to anchor a new one)") - - stale = [] - content_types = unpacked_dir / "[Content_Types].xml" - if content_types.exists() and f'PartName="/ppt/slides/{dest}"' in content_types.read_text(encoding="utf-8"): - stale.append("[Content_Types].xml") - pres_rels = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - if pres_rels.exists() and _find_slide_relationship( - pres_rels.read_text(encoding="utf-8"), dest - ): - stale.append("presentation.xml.rels") - if stale: - _die( - f"{dest} is still registered in {' and '.join(stale)} but absent from ppt/slides/ — " - f"run clean.py first" - ) - - if not after: - return None - after_rid = _rid_for_slide(unpacked_dir, after) - if not re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml): - _die(f"{after} ({after_rid}) is not listed in ") - return after_rid - - -def _register_slide(unpacked_dir: Path, dest: str, source_desc: str, after_rid: str | None) -> None: - _add_to_content_types(unpacked_dir, dest) - rid = _add_to_presentation_rels(unpacked_dir, dest) - slide_id = _get_next_slide_id(unpacked_dir) - pos, total = _insert_into_sld_id_lst(unpacked_dir, slide_id, rid, after_rid) - - print(f"Created ppt/slides/{dest} from {source_desc}") - print( - f'Inserted into ' - f"at position {pos} of {total}" - ) - - -def _add_to_content_types(unpacked_dir: Path, dest: str) -> None: - content_types_path = unpacked_dir / "[Content_Types].xml" - content_types = content_types_path.read_text(encoding="utf-8") - - new_override = f'' - - if f'PartName="/ppt/slides/{dest}"' not in content_types: - content_types = content_types.replace("", f" {new_override}\n") - content_types_path.write_text(content_types, encoding="utf-8") - - -def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str: - pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - pres_rels = pres_rels_path.read_text(encoding="utf-8") - - existing = _find_slide_relationship(pres_rels, dest) - if existing: - return existing - - pres_xml = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") - used = {int(n) for n in re.findall(r'\bId="rId(\d+)"', pres_rels)} - used |= {int(n) for n in re.findall(r'\br:id="rId(\d+)"', pres_xml)} - rid = f"rId{max(used) + 1 if used else 1}" - - new_rel = f'' - pres_rels = pres_rels.replace("", f" {new_rel}\n") - pres_rels_path.write_text(pres_rels, encoding="utf-8") - - return rid - - -def _find_slide_relationship(pres_rels: str, slide_name: str) -> str | None: - for m in re.finditer(r"]*>", pres_rels): - element = m.group(0) - if re.search(rf'Target="(?:/ppt/)?slides/{re.escape(slide_name)}"', element): - id_match = re.search(r'\bId="([^"]+)"', element) - if id_match: - return id_match.group(1) - return None - - -def _get_next_slide_id(unpacked_dir: Path) -> int: - pres_content = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") - used = {int(m) for m in re.findall(r']*\bid="(\d+)"', pres_content)} - - candidate = max((i for i in used if i >= SLIDE_ID_MIN), default=SLIDE_ID_MIN - 1) + 1 - if candidate <= SLIDE_ID_MAX and candidate not in used: - return candidate - for i in range(SLIDE_ID_MIN, SLIDE_ID_MAX + 1): - if i not in used: - return i - _die("no slide id available in [256, 2147483647] — the deck is full") - - -def _insert_into_sld_id_lst( - unpacked_dir: Path, slide_id: int, rid: str, after_rid: str | None = None -) -> tuple[int, int]: - pres_path = unpacked_dir / "ppt" / "presentation.xml" - xml = pres_path.read_text(encoding="utf-8") - entry = f'' - - if f'r:id="{rid}"' in xml: - _die(f"presentation.xml already references {rid}; refusing to add a duplicate") - - if after_rid: - open_tag = re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml) - if not open_tag: - _die(f"{after_rid} is not listed in ") - end = open_tag.end() - if not open_tag.group(0).endswith("/>"): - close = xml.find("", end) - if close == -1: - _die(f"unclosed for {after_rid} in presentation.xml") - end = close + len("") - xml = xml[:end] + entry + xml[end:] - elif "" in xml: - xml = xml.replace("", f"{entry}", 1) - elif re.search(r"", xml): - xml = re.sub(r"", f"{entry}", xml, count=1) - elif "" in xml: - xml = xml.replace( - "", f"{entry}", 1 - ) - else: - _die("presentation.xml has no (or to anchor a new one)") - - pres_path.write_text(xml, encoding="utf-8") - - lst = re.search(r"(.*)", xml, re.DOTALL) - entries = re.findall(r"]*>", lst.group(1)) if lst else [] - position = next( - (i for i, e in enumerate(entries, 1) if f'r:id="{rid}"' in e), len(entries) - ) - return position, len(entries) - - -def _rid_for_slide(unpacked_dir: Path, slide_name: str) -> str: - pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - rid = _find_slide_relationship(pres_rels_path.read_text(encoding="utf-8"), slide_name) - if not rid: - _die(f"{slide_name} has no relationship in presentation.xml.rels") - return rid - - -def add_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: - source_type, layout_file = parse_source(source) - if source_type == "layout" and layout_file is not None: - return create_slide_from_layout(unpacked_dir, layout_file, after) - return duplicate_slide(unpacked_dir, source, after) - - -def add_slide_to_package( - package: Path, source: str, after: str | None = None, output: Path | None = None -) -> str: - out = output or package - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - with zipfile.ZipFile(package) as zf: - safe_extract(zf, tmp_path) - dest = add_slide(tmp_path, source, after) - rezip(tmp_path, out) - print(f"Wrote {out} — the new slide is ppt/slides/{dest} inside it (unpack to edit its content)") - return dest - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Add a slide to a PPTX: duplicate a slide or instantiate a layout. " - "Registers content types, relationships, and ." - ) - parser.add_argument("target", help="Unpacked PPTX directory OR a .pptx/.potx file") - parser.add_argument( - "source", - help="slideN.xml to duplicate, or slideLayoutN.xml to create from a layout " - "(list layouts with: ls /ppt/slideLayouts/)", - ) - parser.add_argument( - "--after", - metavar="SLIDE", - help="insert after this slide, e.g. slide2.xml (default: append at the end)", - ) - parser.add_argument( - "-o", - "--output", - help="output file (only with a .pptx/.potx target; default: rewrite the input in place)", - ) - args = parser.parse_args() - - target = Path(args.target) - if target.is_dir(): - if args.output: - parser.error("--output is only valid for .pptx/.potx input; a directory is modified in place") - add_slide(target, args.source, args.after) - elif target.is_file() and target.suffix.lower() in (".pptx", ".potx"): - try: - add_slide_to_package(target, args.source, args.after, Path(args.output) if args.output else None) - except (OSError, ValueError, zipfile.BadZipFile) as e: - _die(str(e)) - else: - _die(f"{target} is neither a directory nor a .pptx/.potx file") - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py deleted file mode 100755 index 551dd23192..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/clean.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Remove unreferenced files from an unpacked PPTX directory. - -Usage: python clean.py - -Example: - python clean.py unpacked/ - -This script removes: -- Orphaned slides (not in sldIdLst) and their relationships -- [trash] directory (unreferenced files) -- Orphaned .rels files for deleted resources -- Unreferenced media, embeddings, charts, diagrams, drawings, ink files -- Unreferenced theme files -- Unreferenced notes slides -- Content-Type overrides for deleted files -""" - -import posixpath -import re -import sys -from pathlib import Path - -import defusedxml.minidom - -from office.helpers import SLIDE_REL_TYPE, opc_target, rels_source_part - - -def _slide_rids(pres_rels_path: Path, unpacked_dir: Path) -> dict[str, str]: - source_part = rels_source_part(pres_rels_path, unpacked_dir) - rels_dom = defusedxml.minidom.parse(str(pres_rels_path)) - - rids: dict[str, str] = {} - for rel in rels_dom.getElementsByTagName("Relationship"): - if rel.getAttribute("Type") != SLIDE_REL_TYPE: - continue - part = opc_target( - rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") - ) - if part is not None: - rids[rel.getAttribute("Id")] = part - return rids - - -def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]: - pres_path = unpacked_dir / "ppt" / "presentation.xml" - pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - - if not pres_path.exists() or not pres_rels_path.exists(): - return set() - - rid_to_slide = _slide_rids(pres_rels_path, unpacked_dir) - - pres_content = pres_path.read_text(encoding="utf-8") - referenced_rids = set(re.findall(r']*r:id="([^"]+)"', pres_content)) - - return { - posixpath.basename(rid_to_slide[rid]) - for rid in referenced_rids - if rid in rid_to_slide - } - - -class RefusedToClean(Exception): - """The package does not look the way a readable package should.""" - - -def remove_orphaned_slides(unpacked_dir: Path) -> list[str]: - slides_dir = unpacked_dir / "ppt" / "slides" - slides_rels_dir = slides_dir / "_rels" - pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" - - if not slides_dir.exists(): - return [] - - referenced_slides = get_slides_in_sldidlst(unpacked_dir) - on_disk = sorted(slides_dir.glob("slide*.xml")) - - if on_disk and not any(s.name in referenced_slides for s in on_disk): - listed = re.findall( - r']*r:id="([^"]+)"', - (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") - if (unpacked_dir / "ppt" / "presentation.xml").exists() - else "", - ) - if listed: - raise RefusedToClean( - f" lists {len(listed)} slide(s) and none of the " - f"{len(on_disk)} slide(s) on disk match any of them. Refusing to " - f"delete them all — this is a parse failure, not an empty deck." - ) - - removed = [] - - for slide_file in on_disk: - if slide_file.name not in referenced_slides: - rel_path = slide_file.relative_to(unpacked_dir) - slide_file.unlink() - removed.append(str(rel_path)) - - rels_file = slides_rels_dir / f"{slide_file.name}.rels" - if rels_file.exists(): - rels_file.unlink() - removed.append(str(rels_file.relative_to(unpacked_dir))) - - if removed and pres_rels_path.exists(): - rels_dom = defusedxml.minidom.parse(str(pres_rels_path)) - source_part = rels_source_part(pres_rels_path, unpacked_dir) - changed = False - - for rel in list(rels_dom.getElementsByTagName("Relationship")): - if rel.getAttribute("Type") != SLIDE_REL_TYPE: - continue - part = opc_target( - rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") - ) - if part is None: - continue - if posixpath.basename(part) not in referenced_slides: - if rel.parentNode: - rel.parentNode.removeChild(rel) - changed = True - - if changed: - with open(pres_rels_path, "wb") as f: - f.write(rels_dom.toxml(encoding="utf-8")) - - return removed - - -def remove_trash_directory(unpacked_dir: Path) -> list[str]: - trash_dir = unpacked_dir / "[trash]" - removed = [] - - if trash_dir.exists() and trash_dir.is_dir(): - for file_path in trash_dir.iterdir(): - if file_path.is_file(): - rel_path = file_path.relative_to(unpacked_dir) - removed.append(str(rel_path)) - file_path.unlink() - trash_dir.rmdir() - - return removed - - -def _referenced_by(rels_files, unpacked_dir: Path) -> set: - referenced = set() - - for rels_file in rels_files: - source_part = rels_source_part(rels_file, unpacked_dir) - dom = defusedxml.minidom.parse(str(rels_file)) - for rel in dom.getElementsByTagName("Relationship"): - part = opc_target( - rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode") - ) - if part is not None: - referenced.add(Path(part)) - - return referenced - - -def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]: - resource_dirs = ["charts", "diagrams", "drawings"] - removed = [] - - for dir_name in resource_dirs: - rels_dir = unpacked_dir / "ppt" / dir_name / "_rels" - if not rels_dir.exists(): - continue - - for rels_file in rels_dir.glob("*.rels"): - resource_file = rels_dir.parent / rels_file.name.replace(".rels", "") - if not resource_file.exists(): - rels_file.unlink() - removed.append(str(rels_file.relative_to(unpacked_dir))) - - return removed - - -def get_referenced_files(unpacked_dir: Path) -> set: - return _referenced_by(sorted(unpacked_dir.rglob("*.rels")), unpacked_dir) - - -def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]: - resource_dirs = ["media", "embeddings", "charts", "diagrams", "tags", "drawings", "ink"] - removed = [] - - for dir_name in resource_dirs: - dir_path = unpacked_dir / "ppt" / dir_name - if not dir_path.exists(): - continue - - for file_path in dir_path.glob("*"): - if not file_path.is_file(): - continue - rel_path = file_path.relative_to(unpacked_dir) - if rel_path not in referenced: - file_path.unlink() - removed.append(str(rel_path)) - - theme_dir = unpacked_dir / "ppt" / "theme" - if theme_dir.exists(): - for file_path in theme_dir.glob("theme*.xml"): - rel_path = file_path.relative_to(unpacked_dir) - if rel_path not in referenced: - file_path.unlink() - removed.append(str(rel_path)) - theme_rels = theme_dir / "_rels" / f"{file_path.name}.rels" - if theme_rels.exists(): - theme_rels.unlink() - removed.append(str(theme_rels.relative_to(unpacked_dir))) - - notes_dir = unpacked_dir / "ppt" / "notesSlides" - if notes_dir.exists(): - for file_path in notes_dir.glob("*.xml"): - if not file_path.is_file(): - continue - rel_path = file_path.relative_to(unpacked_dir) - if rel_path not in referenced: - file_path.unlink() - removed.append(str(rel_path)) - - notes_rels_dir = notes_dir / "_rels" - if notes_rels_dir.exists(): - for file_path in notes_rels_dir.glob("*.rels"): - notes_file = notes_dir / file_path.name.replace(".rels", "") - if not notes_file.exists(): - file_path.unlink() - removed.append(str(file_path.relative_to(unpacked_dir))) - - return removed - - -def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None: - ct_path = unpacked_dir / "[Content_Types].xml" - if not ct_path.exists(): - return - - dom = defusedxml.minidom.parse(str(ct_path)) - changed = False - - for override in list(dom.getElementsByTagName("Override")): - part_name = override.getAttribute("PartName").lstrip("/") - if part_name in removed_files: - if override.parentNode: - override.parentNode.removeChild(override) - changed = True - - if changed: - with open(ct_path, "wb") as f: - f.write(dom.toxml(encoding="utf-8")) - - -def clean_unused_files(unpacked_dir: Path) -> list[str]: - all_removed = [] - - if list(unpacked_dir.rglob("*.rels")) and not get_referenced_files(unpacked_dir): - raise RefusedToClean( - "no relationship in this package names a part we can resolve. " - "Refusing to treat every file as unreferenced." - ) - - slides_removed = remove_orphaned_slides(unpacked_dir) - all_removed.extend(slides_removed) - - trash_removed = remove_trash_directory(unpacked_dir) - all_removed.extend(trash_removed) - - while True: - removed_rels = remove_orphaned_rels_files(unpacked_dir) - referenced = get_referenced_files(unpacked_dir) - removed_files = remove_orphaned_files(unpacked_dir, referenced) - - total_removed = removed_rels + removed_files - if not total_removed: - break - - all_removed.extend(total_removed) - - if all_removed: - update_content_types(unpacked_dir, all_removed) - - return all_removed - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python clean.py ", file=sys.stderr) - print("Example: python clean.py unpacked/", file=sys.stderr) - sys.exit(1) - - unpacked_dir = Path(sys.argv[1]) - - if not unpacked_dir.exists(): - print(f"Error: {unpacked_dir} not found", file=sys.stderr) - sys.exit(1) - - try: - removed = clean_unused_files(unpacked_dir) - except (RefusedToClean, ValueError) as e: - print(f"Error: {e}", file=sys.stderr) - print("Nothing was deleted.", file=sys.stderr) - sys.exit(1) - - if removed: - print(f"Removed {len(removed)} unreferenced files:") - for f in removed: - print(f" {f}") - else: - print("No unreferenced files found") diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py deleted file mode 100644 index 188b00aff4..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/__init__.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import posixpath -import re -import stat -import tempfile -import urllib.parse -import zipfile -from pathlib import Path - -OOXML_FAMILY = { - ".docx": "docx", - ".dotx": "docx", - ".pptx": "pptx", - ".potx": "pptx", - ".xlsx": "xlsx", - ".xltx": "xlsx", -} - -_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") - -SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" - -MAX_ARCHIVE_MEMBERS = 10_000 -MAX_ARCHIVE_MEMBER_SIZE = 1 * 1024 * 1024 * 1024 -MAX_ARCHIVE_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 -MAX_ARCHIVE_COMPRESSION_RATIO = 1_000 - - -def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: - if not target: - return None - if target_mode.lower() == "external": - return None - if _SCHEME_RE.match(target): - return None - - target = urllib.parse.unquote(target) - - if "\\" in target: - raise ValueError(f"relationship target is not a POSIX part name: {target!r}") - - if target.startswith("/"): - joined = target.lstrip("/") - else: - joined = posixpath.join(posixpath.dirname(source_part), target) - - parts: list[str] = [] - for segment in posixpath.normpath(joined).split("/"): - if segment in ("", "."): - continue - if segment == "..": - if not parts: - raise ValueError(f"relationship target escapes the package: {target!r}") - parts.pop() - else: - parts.append(segment) - - if not parts: - raise ValueError(f"relationship target resolves to nothing: {target!r}") - return "/".join(parts) - - -def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: - owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) - return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") - - -def part_text(data: bytes) -> str: - return data.decode("utf-8", "surrogateescape") - - -XML_SPACE = " \t\r\n" - - -def rendered_text(text: str, preserve: bool) -> str: - return text if preserve else text.strip(XML_SPACE) - - -def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: - dest = dest.resolve() - members = zf.infolist() - if len(members) > MAX_ARCHIVE_MEMBERS: - raise ValueError(f"archive has too many entries: {len(members)}") - - total_size = 0 - targets: set[str] = set() - file_targets: set[str] = set() - validated: list[tuple[zipfile.ZipInfo, Path]] = [] - for m in members: - if stat.S_ISLNK(m.external_attr >> 16): - raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") - target = (dest / m.filename).resolve() - if target == dest or not target.is_relative_to(dest): - raise ValueError(f"unsafe archive entry: {m.filename!r}") - target_key = os.path.normcase(str(target)) - if target_key in targets: - raise ValueError(f"duplicate archive entry: {m.filename!r}") - targets.add(target_key) - if not m.is_dir(): - file_targets.add(target_key) - validated.append((m, target)) - if m.file_size > MAX_ARCHIVE_MEMBER_SIZE: - raise ValueError(f"archive entry is too large: {m.filename!r}") - total_size += m.file_size - if total_size > MAX_ARCHIVE_TOTAL_SIZE: - raise ValueError("archive expands beyond the allowed total size") - if m.file_size and ( - m.compress_size == 0 - or m.file_size > m.compress_size * MAX_ARCHIVE_COMPRESSION_RATIO - ): - raise ValueError(f"archive entry has an unsafe compression ratio: {m.filename!r}") - - for m, target in validated: - for parent in target.parents: - if parent == dest: - break - if os.path.normcase(str(parent)) in file_targets: - raise ValueError(f"archive file entry conflicts with child path: {m.filename!r}") - - for m, _ in validated: - zf.extract(m, dest) - - -def rezip(src_dir: Path, out_path: Path) -> None: - files = sorted(p for p in src_dir.rglob("*") if p.is_file()) - ct = src_dir / "[Content_Types].xml" - fd, tmp_name = tempfile.mkstemp( - prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent - ) - tmp_out = Path(tmp_name) - try: - with os.fdopen(fd, "wb") as fh: - with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: - if ct.exists(): - zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) - for f in files: - if f == ct: - continue - zf.write(f, f.relative_to(src_dir)) - if out_path.exists(): - mode = out_path.stat().st_mode & 0o777 - else: - umask = os.umask(0) - os.umask(umask) - mode = 0o666 & ~umask - os.chmod(tmp_out, mode) - os.replace(tmp_out, out_path) - finally: - if tmp_out.exists(): - tmp_out.unlink() diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_chart.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_chart.py deleted file mode 100644 index 209cb7c58b..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_chart.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Find chart XML that PowerPoint refuses but the schema accepts. - -Detection only: for either fault more than one repair is valid, and only the -author knows which was meant. -""" - - -from __future__ import annotations - -import re -from typing import Mapping - -from . import part_text - - -_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") - -_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") -_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") - -def _strip_ext_lst(text: str) -> str: - out, cursor = [], 0 - for lo, hi in _ext_lst_spans(text): - out.append(text[cursor:lo]) - cursor = hi - out.append(text[cursor:]) - return "".join(out) - -_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) -ILLEGAL_ON_STACKED = frozenset({"outEnd"}) -LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") - - -def _check_stacked_label_positions(part: str, xml: str) -> list[str]: - problems: list[str] = [] - for match in _BAR_GROUP_RE.finditer(xml): - block = _strip_ext_lst(match.group(0)) - group = match.group(1) - - grouping = _GROUPING_RE.search(block) - if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: - continue - - bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] - for pos in sorted(set(bad)): - problems.append( - f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' - f"{grouping.group(1)} {group}; PowerPoint allows only " - f"{', '.join(LEGAL_ON_STACKED)} there" - ) - return problems - - - -_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -_AXID_RE = re.compile( - r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" -) - -_AXIS_DECL_RE = re.compile( - r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" -) - -AXID_LIMIT = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, - "surfaceChart": 3, "surface3DChart": 3, -} - -AXID_MINIMUM = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, - "line3DChart": 3, "surface3DChart": 3, -} - - -def _declared_axes(xml: str) -> dict[str, list[str]]: - axes: dict[str, list[str]] = {} - for kind, axid in _AXIS_DECL_RE.findall(xml): - axes.setdefault(kind, []).append(axid) - return axes - - -def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: - category = axes.get("catAx", []) + axes.get("dateAx", []) - value = axes.get("valAx", []) - series = axes.get("serAx", []) - if len(category) != 1 or len(value) != 1 or len(series) > 1: - return None - ids = [category[0], value[0]] - if limit >= 3 and series: - ids.append(series[0]) - return ids - - -def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: - if kind not in AXID_LIMIT: - return None - ids = _AXID_RE.findall(block) - declared = {i for group in axes.values() for i in group} - if len([i for i in ids if i in declared]) >= 2: - return None - return ids - - -def _check_chart_axis_references(part: str, xml: str) -> list[str]: - axes = _declared_axes(xml) - problems: list[str] = [] - declared = {i for group in axes.values() for i in group} - for match in _ANY_CHART_GROUP_RE.finditer(xml): - kind, block = match.group(1), match.group(0) - ids = _undeclared_axes(kind, block, axes) - if ids is None: - continue - if not ids: - problems.append( - f"{part}: declares no this part can resolve; a chart " - f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" - ) - continue - dead = [i for i in ids if i not in declared] - canonical = _canonical_ids(axes, AXID_LIMIT[kind]) - if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: - hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" - else: - hint = ("Fix: the part declares several axes of a kind -- declare the " - "secondary axes the series expects, or drop them") - detail = (f"of which {', '.join(dead)} name no declared axis" - if dead else f"only {len(ids)} of which this part declares") - problems.append( - f"{part}: references axId {', '.join(ids)}, {detail}, " - f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" - ) - return problems - - -def _ext_lst_spans(text: str) -> list[tuple[int, int]]: - spans: list[tuple[int, int]] = [] - depth = 0 - start = 0 - for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): - closing, self_closing = match.group(1), match.group(2) - if self_closing: - continue - if closing: - depth -= 1 - if depth == 0: - spans.append((start, match.end())) - else: - if depth == 0: - start = match.start() - depth += 1 - return spans - - -CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) - - -def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: - problems: list[str] = [] - for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): - xml = part_text(files[part]) - for check in CHART_CHECKS: - problems.extend(check(part, xml)) - return problems diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_slide.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_slide.py deleted file mode 100644 index 22f9aee0ff..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_slide.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Pick the slide-XML schema errors PowerPoint refuses the file over. - -A denylist over lxml's messages, so an unrecognised error class is a miss rather -than a false alarm. -""" - - -from __future__ import annotations - -import re - -SLIDE_PART_RE = re.compile( - r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" - r"/[^/]+\.xml" -) - -FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( - ( - re.compile(r"\}tableStyleId': This element is not expected"), - "two in one (the schema allows one)", - ), - ( - re.compile(r"\}srgbClr', attribute 'val'"), - "a colour that is not six hex digits", - ), - ( - re.compile(r"\}txBody': Missing child element"), - "a with no children", - ), - ( - re.compile(r"\}miter', attribute 'lim'"), - 'a line join with lim="NaN"', - ), - ( - re.compile(r"\}uLnTx': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}overrideClrMapping': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}nvGrpSpPr': Missing child element"), - "a with no children", - ), -) - - -def is_schema_verdict(error: str) -> bool: - return error.startswith("Element ") - - -def fatal_slide_errors(errors: set[str]) -> list[str]: - out = [] - for error in sorted(errors): - for pattern, meaning in FATAL_SLIDE_ERRORS: - if pattern.search(error): - out.append(f"{meaning}: {error}") - break - return out diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_theme.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_theme.py deleted file mode 100644 index 5ef4c3e835..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/helpers/pptx_theme.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Find masters sharing a theme part in the way PowerPoint refuses to open. - -Reports only; the fix is to move back to directly after - in ppt/presentation.xml. -""" - - -from __future__ import annotations - -import posixpath -import re -from typing import Mapping - -from . import part_text - -THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" - -_MASTER_RE = re.compile( - r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" - r"(?:slide|notes|handout)Master(?P\d+)\.xml$" -) -_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} - -_RELATIONSHIP_RE = re.compile( - r"]*?(?:/>|>.*?)", re.DOTALL -) - - -def _sort_key(name: str) -> tuple[int, int]: - m = _MASTER_RE.match(name) - assert m is not None - return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) - - -def _rels_path(part: str) -> str: - directory, base = posixpath.split(part) - return f"{directory}/_rels/{base}.rels" - - -def _resolve(rels_path: str, target: str) -> str: - if target.startswith("/"): - return target.lstrip("/") - part_dir = posixpath.dirname(posixpath.dirname(rels_path)) - return posixpath.normpath(posixpath.join(part_dir, target)) - - -def _theme_rel(files: Mapping[str, bytes], master: str): - rels_path = _rels_path(master) - rels = files.get(rels_path) - if rels is None: - return None - for element in _RELATIONSHIP_RE.findall(part_text(rels)): - if f'Type="{THEME_REL_TYPE}"' not in element: - continue - target = re.search(r'\bTarget="([^"]+)"', element) - if target is None: - continue - return rels_path, element, _resolve(rels_path, target.group(1)) - return None - - -def _masters(files: Mapping[str, bytes]) -> list[str]: - return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) - - -_PRESENTATION = "ppt/presentation.xml" -_NOTES_MASTERS = "ppt/notesMasters/" -_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) -_AFTER_SLDIDLST_RE = re.compile( - r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL -) - - -def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: - data = files.get(_PRESENTATION) - if data is None: - return False - match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) - return match is not None and match.group(1) == " bool: - return inert_notes and master.startswith(_NOTES_MASTERS) - - -def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - ] - - -def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - inert_notes = _notes_master_share_is_inert(files) - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - if not _is_inert(master, inert_notes) - ] diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd deleted file mode 100644 index 6454ef9a94..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +++ /dev/null @@ -1,1499 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd deleted file mode 100644 index afa4f463e3..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd deleted file mode 100644 index 64e66b8abd..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +++ /dev/null @@ -1,1085 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd deleted file mode 100644 index 687eea8297..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd deleted file mode 100644 index 6ac81b06b7..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +++ /dev/null @@ -1,3081 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd deleted file mode 100644 index 1dbf05140d..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd deleted file mode 100644 index f1af17db4e..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd deleted file mode 100644 index 0a185ab6ed..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd deleted file mode 100644 index 14ef488865..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +++ /dev/null @@ -1,1676 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd deleted file mode 100644 index c20f3bf147..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd deleted file mode 100644 index ac60252262..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd deleted file mode 100644 index 424b8ba8d1..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd deleted file mode 100644 index 2bddce2921..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd deleted file mode 100644 index 8a8c18ba2d..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd deleted file mode 100644 index 5c42706a0d..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd deleted file mode 100644 index 853c341c87..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd deleted file mode 100644 index da835ee82d..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd deleted file mode 100644 index 87ad2658fa..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +++ /dev/null @@ -1,582 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd deleted file mode 100644 index 9e86f1b2be..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd deleted file mode 100644 index d0be42e757..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +++ /dev/null @@ -1,4439 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd deleted file mode 100644 index 8821dd183c..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +++ /dev/null @@ -1,570 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd deleted file mode 100644 index ca2575c753..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +++ /dev/null @@ -1,509 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd deleted file mode 100644 index dd079e603f..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd deleted file mode 100644 index 3dd6cf625a..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd deleted file mode 100644 index f1041e34ef..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd deleted file mode 100644 index 9c5b7a6334..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +++ /dev/null @@ -1,3646 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd deleted file mode 100644 index 0f13678d80..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - See http://www.w3.org/XML/1998/namespace.html and - http://www.w3.org/TR/REC-xml for information about this namespace. - - This schema document describes the XML namespace, in a form - suitable for import by other schema documents. - - Note that local names in this namespace are intended to be defined - only by the World Wide Web Consortium or its subgroups. The - following names are currently defined in this namespace and should - not be used with conflicting semantics by any Working Group, - specification, or document instance: - - base (as an attribute name): denotes an attribute whose value - provides a URI to be used as the base for interpreting any - relative URIs in the scope of the element on which it - appears; its value is inherited. This name is reserved - by virtue of its definition in the XML Base specification. - - lang (as an attribute name): denotes an attribute whose value - is a language code for the natural language of the content of - any element; its value is inherited. This name is reserved - by virtue of its definition in the XML specification. - - space (as an attribute name): denotes an attribute whose - value is a keyword indicating what whitespace processing - discipline is intended for the content of the element; its - value is inherited. This name is reserved by virtue of its - definition in the XML specification. - - Father (in any context at all): denotes Jon Bosak, the chair of - the original XML Working Group. This name is reserved by - the following decision of the W3C XML Plenary and - XML Coordination groups: - - In appreciation for his vision, leadership and dedication - the W3C XML Plenary on this 10th day of February, 2000 - reserves for Jon Bosak in perpetuity the XML name - xml:Father - - - - - This schema defines attributes and an attribute group - suitable for use by - schemas wishing to allow xml:base, xml:lang or xml:space attributes - on elements they define. - - To enable this, such a schema must import this schema - for the XML namespace, e.g. as follows: - <schema . . .> - . . . - <import namespace="http://www.w3.org/XML/1998/namespace" - schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> - - Subsequently, qualified reference to any of the attributes - or the group defined below will have the desired effect, e.g. - - <type . . .> - . . . - <attributeGroup ref="xml:specialAttrs"/> - - will define a type which will schema-validate an instance - element with any of those attributes - - - - In keeping with the XML Schema WG's standard versioning - policy, this schema document will persist at - http://www.w3.org/2001/03/xml.xsd. - At the date of issue it can also be found at - http://www.w3.org/2001/xml.xsd. - The schema document at that URI may however change in the future, - in order to remain compatible with the latest version of XML Schema - itself. In other words, if the XML Schema namespace changes, the version - of this document at - http://www.w3.org/2001/xml.xsd will change - accordingly; the version at - http://www.w3.org/2001/03/xml.xsd will not change. - - - - - - In due course, we should install the relevant ISO 2- and 3-letter - codes as the enumerated possible values . . . - - - - - - - - - - - - - - - See http://www.w3.org/TR/xmlbase/ for - information about this attribute. - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd deleted file mode 100644 index a6de9d2733..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd deleted file mode 100644 index 10e978b661..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd deleted file mode 100644 index 4248bf7a39..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd deleted file mode 100644 index 5649746712..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd deleted file mode 100644 index ef725457cf..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd deleted file mode 100644 index f65f777730..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd +++ /dev/null @@ -1,560 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd deleted file mode 100644 index 6b00755a9a..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd deleted file mode 100644 index f321d333a5..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd deleted file mode 100644 index 364c6a9b8d..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd deleted file mode 100644 index fed9d15b7f..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd deleted file mode 100644 index 680cf15400..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd deleted file mode 100644 index 89ada90837..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py deleted file mode 100644 index 0b4c99deca..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/soffice.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Helper for running LibreOffice (soffice) in environments where AF_UNIX -sockets may be blocked (e.g., sandboxed VMs). Detects the restriction -at runtime and applies an LD_PRELOAD shim if needed. - -Usage: - from office.soffice import run_soffice - - result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) - -Call soffice through run_soffice, not through subprocess with get_soffice_env(): -the env dict carries the shim but names no user profile, and a non-root sandbox -cannot bootstrap the default one -- soffice aborts with "User installation could -not be completed" and converts nothing. get_soffice_env() stays public for the -callers that build their own argv (they must pass -env:UserInstallation too). -""" - -import contextlib -import os -import socket -import subprocess -import tempfile -from collections.abc import Iterable -from pathlib import Path - - -def get_soffice_env() -> dict: - env = os.environ.copy() - env["SAL_USE_VCLPLUGIN"] = "svp" - - if _needs_shim(): - shim = _ensure_shim() - env["LD_PRELOAD"] = str(shim) - - return env - - -def run_soffice(args: Iterable[str], **kwargs) -> subprocess.CompletedProcess: - args = list(args) - with contextlib.ExitStack() as stack: - if not any(str(a).startswith("-env:UserInstallation") for a in args): - profile = stack.enter_context( - tempfile.TemporaryDirectory(prefix="lo_profile_", ignore_cleanup_errors=True) - ) - args = [f"-env:UserInstallation={Path(profile).as_uri()}"] + args - return subprocess.run(["soffice"] + args, env=get_soffice_env(), **kwargs) - - - -_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" - - -def _needs_shim() -> bool: - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.close() - return False - except OSError: - return True - - -def _ensure_shim() -> Path: - if _SHIM_SO.exists(): - return _SHIM_SO - - src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" - src.write_text(_SHIM_SOURCE) - subprocess.run( - ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], - check=True, - capture_output=True, - ) - src.unlink() - return _SHIM_SO - - - -_SHIM_SOURCE = r""" -#define _GNU_SOURCE -#include -#include -#include -#include -#include -#include -#include - -static int (*real_socket)(int, int, int); -static int (*real_socketpair)(int, int, int, int[2]); -static int (*real_listen)(int, int); -static int (*real_accept)(int, struct sockaddr *, socklen_t *); -static int (*real_close)(int); -static int (*real_read)(int, void *, size_t); - -/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ -static int is_shimmed[1024]; -static int peer_of[1024]; -static int wake_r[1024]; /* accept() blocks reading this */ -static int wake_w[1024]; /* close() writes to this */ -static int listener_fd = -1; /* FD that received listen() */ - -__attribute__((constructor)) -static void init(void) { - real_socket = dlsym(RTLD_NEXT, "socket"); - real_socketpair = dlsym(RTLD_NEXT, "socketpair"); - real_listen = dlsym(RTLD_NEXT, "listen"); - real_accept = dlsym(RTLD_NEXT, "accept"); - real_close = dlsym(RTLD_NEXT, "close"); - real_read = dlsym(RTLD_NEXT, "read"); - for (int i = 0; i < 1024; i++) { - peer_of[i] = -1; - wake_r[i] = -1; - wake_w[i] = -1; - } -} - -/* ---- socket ---------------------------------------------------------- */ -int socket(int domain, int type, int protocol) { - if (domain == AF_UNIX) { - int fd = real_socket(domain, type, protocol); - if (fd >= 0) return fd; - /* socket(AF_UNIX) blocked – fall back to socketpair(). */ - int sv[2]; - if (real_socketpair(domain, type, protocol, sv) == 0) { - if (sv[0] >= 0 && sv[0] < 1024) { - is_shimmed[sv[0]] = 1; - peer_of[sv[0]] = sv[1]; - int wp[2]; - if (pipe(wp) == 0) { - wake_r[sv[0]] = wp[0]; - wake_w[sv[0]] = wp[1]; - } - } - return sv[0]; - } - errno = EPERM; - return -1; - } - return real_socket(domain, type, protocol); -} - -/* ---- listen ---------------------------------------------------------- */ -int listen(int sockfd, int backlog) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - listener_fd = sockfd; - return 0; - } - return real_listen(sockfd, backlog); -} - -/* ---- accept ---------------------------------------------------------- */ -int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - /* Block until close() writes to the wake pipe. */ - if (wake_r[sockfd] >= 0) { - char buf; - real_read(wake_r[sockfd], &buf, 1); - } - errno = ECONNABORTED; - return -1; - } - return real_accept(sockfd, addr, addrlen); -} - -/* ---- close ----------------------------------------------------------- */ -int close(int fd) { - if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { - int was_listener = (fd == listener_fd); - is_shimmed[fd] = 0; - - if (wake_w[fd] >= 0) { /* unblock accept() */ - char c = 0; - write(wake_w[fd], &c, 1); - real_close(wake_w[fd]); - wake_w[fd] = -1; - } - if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } - if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } - - if (was_listener) - _exit(0); /* conversion done – exit */ - } - return real_close(fd); -} -""" - - - -if __name__ == "__main__": - import sys - result = run_soffice(sys.argv[1:]) - sys.exit(result.returncode) diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py deleted file mode 100755 index 8fbd2f71ca..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validate.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Command line tool to validate Office document XML files against XSD schemas and tracked changes. - -Usage: - python validate.py [--original ] [--auto-repair] [--author NAME] - -The first argument can be either: -- An unpacked directory containing the Office document XML files -- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory - -Auto-repair fixes: -- paraId/durableId values that exceed OOXML limits -- Missing xml:space="preserve" on w:t elements with whitespace -""" - -import argparse -import sys -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import OOXML_FAMILY, rezip, safe_extract -from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator - -WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - -def _fail(message: str): - print(f"Error: {message}", file=sys.stderr) - sys.exit(2) - - -def _has_tracked_changes(unpacked_dir: Path) -> bool: - document = unpacked_dir / "word" / "document.xml" - if not document.is_file(): - return False - try: - root = ET.parse(document).getroot() - except (ET.ParseError, DefusedXmlException): - return False - tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} - return any(elem.tag in tracked for elem in root.iter()) - - -def main(): - parser = argparse.ArgumentParser(description="Validate Office document XML files") - parser.add_argument( - "path", - help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", - ) - parser.add_argument( - "--original", - required=False, - default=None, - help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose output", - ) - parser.add_argument( - "--auto-repair", - action="store_true", - help="Automatically repair common issues (hex IDs, whitespace preservation). " - "Modifies the input in place: repairs to a packed file are written back to it.", - ) - parser.add_argument( - "--author", - default=None, - help="The name you are redlining under. Passing it turns on the " - "tracked-change check: any text differing from --original without a " - "/ recording it is reported. Untracked edits carry no " - "author, so the check covers them whoever made them — the name marks " - "the run as redlining work and is not used to filter. Requires " - "--original; docx only.", - ) - args = parser.parse_args() - - if args.author is not None and not args.original: - _fail("--author requires --original") - - path = Path(args.path) - if not path.exists(): - _fail(f"{path} does not exist") - - original_file = None - if args.original: - original_file = Path(args.original) - if not original_file.is_file(): - _fail(f"{original_file} is not a file") - if original_file.suffix.lower() not in OOXML_FAMILY: - _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") - - family = OOXML_FAMILY.get((original_file or path).suffix.lower()) - if family is None: - _fail( - f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." - ) - - if args.author is not None and family != "docx": - _fail(f"--author only applies to docx files, not {family}") - - packed_file = None - temp_dir_ctx = None - if path.is_file() and path.suffix.lower() in OOXML_FAMILY: - packed_file = path - temp_dir_ctx = tempfile.TemporaryDirectory() - unpacked_dir = Path(temp_dir_ctx.name) - try: - with zipfile.ZipFile(path, "r") as zf: - safe_extract(zf, unpacked_dir) - except (zipfile.BadZipFile, ValueError, OSError) as e: - _fail(f"cannot unpack {path}: {e}") - else: - if not path.is_dir(): - _fail(f"{path} is not a directory or Office file") - unpacked_dir = path - - match family: - case "docx": - validators = [ - DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - if args.author is not None: - validators.append( - RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) - ) - elif original_file and _has_tracked_changes(unpacked_dir): - print( - "Note: this document has tracked changes; they were not " - "checked against the original (pass --author to check)." - ) - case "pptx": - validators = [ - PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - case "xlsx": - exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") - print( - f"No XSD schema validation is performed for xlsx-family files ({exts}). " - "For formula-error checking, use scripts/recalc.py instead." - ) - sys.exit(0) - case _: - print(f"Error: Validation not supported for file type {family}") - sys.exit(1) - - if args.auto_repair: - total_repairs = sum(v.repair() for v in validators) - if total_repairs: - print(f"Auto-repaired {total_repairs} issue(s)") - if packed_file is not None: - rezip(unpacked_dir, packed_file) - print(f"Wrote repaired file to {packed_file}") - - success = all([v.validate() for v in validators]) - - if temp_dir_ctx is not None: - temp_dir_ctx.cleanup() - - if success: - print("All validations PASSED!") - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py deleted file mode 100644 index db092ece7e..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Validation modules for Word document processing. -""" - -from .base import BaseSchemaValidator -from .docx import DOCXSchemaValidator -from .pptx import PPTXSchemaValidator -from .redlining import RedliningValidator - -__all__ = [ - "BaseSchemaValidator", - "DOCXSchemaValidator", - "PPTXSchemaValidator", - "RedliningValidator", -] diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py deleted file mode 100644 index 19d52a7fe0..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/base.py +++ /dev/null @@ -1,875 +0,0 @@ -""" -Base validator with common validation logic for document files. -""" - -import re -from pathlib import Path - -import defusedxml.minidom -from functools import lru_cache - -import lxml.etree - -from helpers import safe_extract - - -@lru_cache(maxsize=None) -def _load_schema(schema_path: str): - with open(schema_path, "rb") as xsd_file: - xsd_doc = lxml.etree.parse( - xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path - ) - return lxml.etree.XMLSchema(xsd_doc) - -class BaseSchemaValidator: - - IGNORED_VALIDATION_ERRORS = [ - "hyphenationZone", - "purl.org/dc/terms", - ] - - UNIQUE_ID_REQUIREMENTS = { - "comment": ("id", "file"), - "commentrangestart": ("id", "file"), - "commentrangeend": ("id", "file"), - "bookmarkstart": ("id", "file"), - "bookmarkend": ("id", "file"), - "sldid": ("id", "file"), - "sldmasterid": ("id", "global"), - "sldlayoutid": ("id", "global"), - "cm": ("authorid", "file"), - "sheet": ("sheetid", "file"), - "definedname": ("id", "file"), - "cxnsp": ("id", "file"), - "sp": ("id", "file"), - "pic": ("id", "file"), - "grpsp": ("id", "file"), - } - - EXCLUDED_ID_CONTAINERS = { - "sectionlst", - } - - ELEMENT_RELATIONSHIP_TYPES = {} - - SCHEMA_MAPPINGS = { - "word": "ISO-IEC29500-4_2016/wml.xsd", - "ppt": "ISO-IEC29500-4_2016/pml.xsd", - "xl": "ISO-IEC29500-4_2016/sml.xsd", - "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", - "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", - "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", - "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", - ".rels": "ecma/fouth-edition/opc-relationships.xsd", - "people.xml": "microsoft/wml-2012.xsd", - "commentsIds.xml": "microsoft/wml-cid-2016.xsd", - "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", - "commentsExtended.xml": "microsoft/wml-2012.xsd", - "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", - "theme": "ISO-IEC29500-4_2016/dml-main.xsd", - "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", - } - - MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" - XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" - - PACKAGE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/relationships" - ) - OFFICE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - ) - CONTENT_TYPES_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/content-types" - ) - - MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} - - OOXML_NAMESPACES = { - "http://schemas.openxmlformats.org/officeDocument/2006/math", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships", - "http://schemas.openxmlformats.org/schemaLibrary/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/chart", - "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/diagram", - "http://schemas.openxmlformats.org/drawingml/2006/picture", - "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", - "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "http://schemas.openxmlformats.org/presentationml/2006/main", - "http://schemas.openxmlformats.org/spreadsheetml/2006/main", - "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", - "http://www.w3.org/XML/1998/namespace", - } - - def __init__(self, unpacked_dir, original_file=None, verbose=False): - self.unpacked_dir = Path(unpacked_dir).resolve() - self.original_file = Path(original_file) if original_file else None - self.verbose = verbose - - self.schemas_dir = Path(__file__).parent.parent / "schemas" - - patterns = ["*.xml", "*.rels"] - self.xml_files = [ - f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) - ] - - if not self.xml_files: - print(f"Warning: No XML files found in {self.unpacked_dir}") - - def validate(self): - raise NotImplementedError("Subclasses must implement the validate method") - - def repair(self) -> int: - return self.repair_whitespace_preservation() - - def repair_whitespace_preservation(self) -> int: - repairs = 0 - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - pending = [] - - for elem in dom.getElementsByTagName("*"): - local_name = elem.tagName.rsplit(":", 1)[-1] - if local_name in ("t", "delText", "instrText", "delInstrText"): - text = "".join( - child.data - for child in elem.childNodes - if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) - ) - ws = (" ", "\t", "\n", "\r") - if text and (text.startswith(ws) or text.endswith(ws)): - if elem.getAttribute("xml:space") != "preserve": - elem.setAttribute("xml:space", "preserve") - text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) - pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") - - if pending: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - def validate_xml(self): - errors = [] - - for xml_file in self.xml_files: - try: - lxml.etree.parse(str(xml_file)) - except lxml.etree.XMLSyntaxError as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {e.lineno}: {e.msg}" - ) - except Exception as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Unexpected error: {str(e)}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} XML violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All XML files are well-formed") - return True - - def validate_namespaces(self): - errors = [] - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - declared = set(root.nsmap.keys()) - {None} - - for attr_val in [ - v for k, v in root.attrib.items() if k.endswith("Ignorable") - ]: - undeclared = set(attr_val.split()) - declared - errors.extend( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Namespace '{ns}' in Ignorable but not declared" - for ns in undeclared - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} namespace issues:") - for error in errors: - print(error) - return False - if self.verbose: - print("PASSED - All namespace prefixes properly declared") - return True - - def validate_unique_ids(self): - errors = [] - global_ids = {} - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - file_ids = {} - - mc_elements = root.xpath( - ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} - ) - for elem in mc_elements: - elem.getparent().remove(elem) - - for elem in root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag = ( - elem.tag.split("}")[-1].lower() - if "}" in elem.tag - else elem.tag.lower() - ) - - if tag in self.UNIQUE_ID_REQUIREMENTS: - in_excluded_container = any( - ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS - for ancestor in elem.iterancestors() - ) - if in_excluded_container: - continue - - attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] - - id_value = None - for attr, value in elem.attrib.items(): - attr_local = ( - attr.split("}")[-1].lower() - if "}" in attr - else attr.lower() - ) - if attr_local == attr_name: - id_value = value - break - - if id_value is not None: - if scope == "global": - if id_value in global_ids: - prev_file, prev_line, prev_tag = global_ids[ - id_value - ] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " - f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" - ) - else: - global_ids[id_value] = ( - xml_file.relative_to(self.unpacked_dir), - elem.sourceline, - tag, - ) - elif scope == "file": - key = (tag, attr_name) - if key not in file_ids: - file_ids[key] = {} - - if id_value in file_ids[key]: - prev_line = file_ids[key][id_value] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " - f"(first occurrence at line {prev_line})" - ) - else: - file_ids[key][id_value] = elem.sourceline - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} ID uniqueness violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All required IDs are unique") - return True - - def validate_file_references(self): - errors = [] - - rels_files = list(self.unpacked_dir.rglob("*.rels")) - - if not rels_files: - if self.verbose: - print("PASSED - No .rels files found") - return True - - all_files = [] - for file_path in self.unpacked_dir.rglob("*"): - if ( - file_path.is_file() - and file_path.name != "[Content_Types].xml" - and not file_path.name.endswith(".rels") - ): - all_files.append(file_path.resolve()) - - all_referenced_files = set() - - if self.verbose: - print( - f"Found {len(rels_files)} .rels files and {len(all_files)} target files" - ) - - for rels_file in rels_files: - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - rels_dir = rels_file.parent - - referenced_files = set() - broken_refs = [] - - for rel in rels_root.findall( - ".//ns:Relationship", - namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, - ): - target = rel.get("Target") - if rel.get("TargetMode") == "External": - continue - if target and not target.startswith( - ("http", "mailto:") - ): - if target.startswith("/"): - target_path = self.unpacked_dir / target.lstrip("/") - elif rels_file.name == ".rels": - target_path = self.unpacked_dir / target - else: - base_dir = rels_dir.parent - target_path = base_dir / target - - try: - target_path = target_path.resolve() - if target_path.exists() and target_path.is_file(): - referenced_files.add(target_path) - all_referenced_files.add(target_path) - else: - broken_refs.append((target, rel.sourceline)) - except (OSError, ValueError): - broken_refs.append((target, rel.sourceline)) - - if broken_refs: - rel_path = rels_file.relative_to(self.unpacked_dir) - for broken_ref, line_num in broken_refs: - errors.append( - f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" - ) - - except Exception as e: - rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append(f" Error parsing {rel_path}: {e}") - - unreferenced_files = set(all_files) - all_referenced_files - - if unreferenced_files: - for unref_file in sorted(unreferenced_files): - unref_rel_path = unref_file.relative_to(self.unpacked_dir) - errors.append(f" Unreferenced file: {unref_rel_path}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship validation errors:") - for error in errors: - print(error) - print( - "CRITICAL: These errors will cause the document to appear corrupt. " - + "Broken references MUST be fixed, " - + "and unreferenced files MUST be referenced or removed." - ) - return False - else: - if self.verbose: - print( - "PASSED - All references are valid and all files are properly referenced" - ) - return True - - def validate_all_relationship_ids(self): - import lxml.etree - - errors = [] - - for xml_file in self.xml_files: - if xml_file.suffix == ".rels": - continue - - rels_dir = xml_file.parent / "_rels" - rels_file = rels_dir / f"{xml_file.name}.rels" - - if not rels_file.exists(): - continue - - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - rid_to_type = {} - - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rid = rel.get("Id") - rel_type = rel.get("Type", "") - if rid: - if rid in rid_to_type: - rels_rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append( - f" {rels_rel_path}: Line {rel.sourceline}: " - f"Duplicate relationship ID '{rid}' (IDs must be unique)" - ) - type_name = ( - rel_type.split("/")[-1] if "/" in rel_type else rel_type - ) - rid_to_type[rid] = type_name - - xml_root = lxml.etree.parse(str(xml_file)).getroot() - - r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE - rid_attrs_to_check = ["id", "embed", "link"] - for elem in xml_root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - for attr_name in rid_attrs_to_check: - rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") - if not rid_attr: - continue - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - elem_name = ( - elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag - ) - - if rid_attr not in rid_to_type: - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " - f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" - ) - elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: - expected_type = self._get_expected_relationship_type( - elem_name - ) - if expected_type: - actual_type = rid_to_type[rid_attr] - if expected_type not in actual_type.lower(): - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " - f"but should point to a '{expected_type}' relationship" - ) - - except Exception as e: - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - errors.append(f" Error processing {xml_rel_path}: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship ID reference errors:") - for error in errors: - print(error) - print("\nThese ID mismatches will cause the document to appear corrupt!") - return False - else: - if self.verbose: - print("PASSED - All relationship ID references are valid") - return True - - def _get_expected_relationship_type(self, element_name): - elem_lower = element_name.lower() - - if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: - return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] - - if elem_lower.endswith("id") and len(elem_lower) > 2: - prefix = elem_lower[:-2] - if prefix.endswith("master"): - return prefix.lower() - elif prefix.endswith("layout"): - return prefix.lower() - else: - if prefix == "sld": - return "slide" - return prefix.lower() - - if elem_lower.endswith("reference") and len(elem_lower) > 9: - prefix = elem_lower[:-9] - return prefix.lower() - - return None - - def validate_content_types(self): - errors = [] - - content_types_file = self.unpacked_dir / "[Content_Types].xml" - if not content_types_file.exists(): - print("FAILED - [Content_Types].xml file not found") - return False - - try: - root = lxml.etree.parse(str(content_types_file)).getroot() - declared_parts = set() - declared_extensions = set() - - for override in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" - ): - part_name = override.get("PartName") - if part_name is not None: - declared_parts.add(part_name.lstrip("/")) - - for default in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" - ): - extension = default.get("Extension") - if extension is not None: - declared_extensions.add(extension.lower()) - - declarable_roots = { - "sld", - "sldLayout", - "sldMaster", - "presentation", - "document", - "workbook", - "worksheet", - "theme", - } - - media_extensions = { - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - "bmp": "image/bmp", - "tiff": "image/tiff", - "wmf": "image/x-wmf", - "emf": "image/x-emf", - } - - all_files = list(self.unpacked_dir.rglob("*")) - all_files = [f for f in all_files if f.is_file()] - - for xml_file in self.xml_files: - path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( - "\\", "/" - ) - - if any( - skip in path_str - for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] - ): - continue - - try: - root_tag = lxml.etree.parse(str(xml_file)).getroot().tag - root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag - - if root_name in declarable_roots and path_str not in declared_parts: - errors.append( - f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" - ) - - except Exception: - continue - - for file_path in all_files: - if file_path.suffix.lower() in {".xml", ".rels"}: - continue - if file_path.name == "[Content_Types].xml": - continue - if "_rels" in file_path.parts or "docProps" in file_path.parts: - continue - - extension = file_path.suffix.lstrip(".").lower() - if extension and extension not in declared_extensions: - if extension in media_extensions: - relative_path = file_path.relative_to(self.unpacked_dir) - errors.append( - f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' - ) - - except Exception as e: - errors.append(f" Error parsing [Content_Types].xml: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} content type declaration errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print( - "PASSED - All content files are properly declared in [Content_Types].xml" - ) - return True - - def validate_file_against_xsd(self, xml_file, verbose=False): - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - - is_valid, current_errors = self._validate_single_file_xsd( - xml_file, unpacked_dir - ) - - if is_valid is None: - return None, set() - elif is_valid: - return True, set() - - original_errors = self._get_original_file_errors(xml_file) - - assert current_errors is not None - new_errors = current_errors - original_errors - - new_errors = { - e for e in new_errors - if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) - } - - if new_errors: - if verbose: - relative_path = xml_file.relative_to(unpacked_dir) - print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") - for error in list(new_errors)[:3]: - truncated = error[:250] + "..." if len(error) > 250 else error - print(f" - {truncated}") - return False, new_errors - else: - if verbose: - print( - f"PASSED - No new errors (original had {len(current_errors)} errors)" - ) - return True, set() - - def validate_against_xsd(self): - new_errors = [] - original_error_count = 0 - valid_count = 0 - skipped_count = 0 - - for xml_file in self.xml_files: - relative_path = str(xml_file.relative_to(self.unpacked_dir)) - is_valid, new_file_errors = self.validate_file_against_xsd( - xml_file, verbose=False - ) - - if is_valid is None: - skipped_count += 1 - continue - elif is_valid and not new_file_errors: - valid_count += 1 - continue - elif is_valid: - original_error_count += 1 - valid_count += 1 - continue - - new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") - for error in list(new_file_errors)[:3]: - new_errors.append( - f" - {error[:250]}..." if len(error) > 250 else f" - {error}" - ) - - if self.verbose: - print(f"Validated {len(self.xml_files)} files:") - print(f" - Valid: {valid_count}") - print(f" - Skipped (no schema): {skipped_count}") - if original_error_count: - print(f" - With original errors (ignored): {original_error_count}") - print( - f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" - ) - - if new_errors: - print("\nFAILED - Found NEW validation errors:") - for error in new_errors: - print(error) - return False - else: - if self.verbose: - print("\nPASSED - No new XSD validation errors introduced") - return True - - def _get_schema_path(self, xml_file): - if xml_file.name in self.SCHEMA_MAPPINGS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] - - if xml_file.suffix == ".rels": - return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] - - if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] - - if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] - - if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] - - return None - - def _clean_ignorable_namespaces(self, xml_doc): - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - for elem in xml_copy.iter(): - attrs_to_remove = [] - - for attr in elem.attrib: - if "{" in attr: - ns = attr.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - attrs_to_remove.append(attr) - - for attr in attrs_to_remove: - del elem.attrib[attr] - - self._remove_ignorable_elements(xml_copy) - - return lxml.etree.ElementTree(xml_copy) - - def _remove_ignorable_elements(self, root): - elements_to_remove = [] - - for elem in list(root): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - - tag_str = str(elem.tag) - if tag_str.startswith("{"): - ns = tag_str.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - elements_to_remove.append(elem) - continue - - self._remove_ignorable_elements(elem) - - for elem in elements_to_remove: - root.remove(elem) - - def _preprocess_for_mc_ignorable(self, xml_doc): - root = xml_doc.getroot() - - if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: - del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] - - return xml_doc - - def _preprocess_for_schema(self, xml_doc, relative_path): - return xml_doc - - def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): - schema_path = schema_path or self._get_schema_path(xml_file) - if not schema_path: - return None, None - - try: - schema = _load_schema(str(schema_path)) - - with open(xml_file, "r") as f: - xml_doc = lxml.etree.parse(f) - - xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) - xml_doc = self._preprocess_for_mc_ignorable(xml_doc) - - relative_path = xml_file.relative_to(base_path) - if ( - relative_path.parts - and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS - ): - xml_doc = self._clean_ignorable_namespaces(xml_doc) - - xml_doc = self._preprocess_for_schema(xml_doc, relative_path) - - if schema.validate(xml_doc): - return True, set() - else: - errors = set() - for error in schema.error_log: - errors.add(error.message) - return False, errors - - except Exception as e: - return False, {str(e)} - - def _get_original_file_errors(self, xml_file, schema_path=None): - if self.original_file is None: - return set() - - import tempfile - import zipfile - - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - relative_path = xml_file.relative_to(unpacked_dir) - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - original_xml_file = temp_path / relative_path - - if not original_xml_file.exists(): - return set() - - is_valid, errors = self._validate_single_file_xsd( - original_xml_file, temp_path, schema_path=schema_path - ) - return errors if errors else set() - - def _remove_template_tags_from_text_nodes(self, xml_doc): - warnings = [] - template_pattern = re.compile(r"\{\{[^}]*\}\}") - - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - def process_text_content(text, content_type): - if not text: - return text - matches = list(template_pattern.finditer(text)) - if matches: - for match in matches: - warnings.append( - f"Found template tag in {content_type}: {match.group()}" - ) - return template_pattern.sub("", text) - return text - - for elem in xml_copy.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag_str = str(elem.tag) - if tag_str.endswith("}t") or tag_str == "t": - continue - - elem.text = process_text_content(elem.text, "text content") - elem.tail = process_text_content(elem.tail, "tail content") - - return lxml.etree.ElementTree(xml_copy), warnings - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py deleted file mode 100644 index 0d18b6979a..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/docx.py +++ /dev/null @@ -1,466 +0,0 @@ -""" -Validator for Word document XML files against XSD schemas. -""" - -import random -import re -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom -import lxml.etree - -from helpers import safe_extract - -from .base import BaseSchemaValidator - - -class DOCXSchemaValidator(BaseSchemaValidator): - - WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" - W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" - - ELEMENT_RELATIONSHIP_TYPES = {} - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_whitespace_preservation(): - all_valid = False - - if not self.validate_deletions(): - all_valid = False - - if not self.validate_insertions(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_id_constraints(): - all_valid = False - - if not self.validate_comment_markers(): - all_valid = False - - self.compare_paragraph_counts() - - return all_valid - - def validate_whitespace_preservation(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): - if elem.text: - text = elem.text - if re.search(r"^[ \t\n\r]", text) or re.search( - r"[ \t\n\r]$", text - ): - xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" - if ( - xml_space_attr not in elem.attrib - or elem.attrib[xml_space_attr] != "preserve" - ): - text_preview = ( - repr(text)[:50] + "..." - if len(repr(text)) > 50 - else repr(text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} whitespace preservation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All whitespace is properly preserved") - return True - - def validate_deletions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): - if t_elem.text: - text_preview = ( - repr(t_elem.text)[:50] + "..." - if len(repr(t_elem.text)) > 50 - else repr(t_elem.text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {t_elem.sourceline}: found within : {text_preview}" - ) - - for instr_elem in root.xpath( - ".//w:del//w:instrText", namespaces=namespaces - ): - text_preview = ( - repr(instr_elem.text or "")[:50] + "..." - if len(repr(instr_elem.text or "")) > 50 - else repr(instr_elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} deletion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:t elements found within w:del elements") - return True - - def count_paragraphs_in_unpacked(self): - count = 0 - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - except Exception as e: - print(f"Error counting paragraphs in unpacked document: {e}") - - return count - - def count_paragraphs_in_original(self): - original = self.original_file - if original is None: - return 0 - - count = 0 - - try: - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(original, "r") as zip_ref: - safe_extract(zip_ref, Path(temp_dir)) - - doc_xml_path = temp_dir + "/word/document.xml" - root = lxml.etree.parse(doc_xml_path).getroot() - - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - - except Exception as e: - print(f"Error counting paragraphs in original document: {e}") - - return count - - def validate_insertions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - invalid_elements = root.xpath( - ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces - ) - - for elem in invalid_elements: - text_preview = ( - repr(elem.text or "")[:50] + "..." - if len(repr(elem.text or "")) > 50 - else repr(elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: within : {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} insertion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:delText elements within w:ins elements") - return True - - def compare_paragraph_counts(self): - new_count = self.count_paragraphs_in_unpacked() - if self.original_file is None: - print(f"\nParagraphs: {new_count}") - return - - original_count = self.count_paragraphs_in_original() - diff = new_count - original_count - diff_str = f"+{diff}" if diff > 0 else str(diff) - print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") - - def _parse_id_value(self, val: str, base: int = 16) -> int: - return int(val, base) - - def validate_id_constraints(self): - errors = [] - para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" - durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" - - for xml_file in self.xml_files: - try: - for elem in lxml.etree.parse(str(xml_file)).iter(): - if val := elem.get(para_id_attr): - try: - if self._parse_id_value(val, base=16) >= 0x80000000: - errors.append( - f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"paraId={val} is not valid hex" - ) - - if val := elem.get(durable_id_attr): - if xml_file.name == "numbering.xml": - try: - if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} must be decimal in numbering.xml" - ) - else: - try: - if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} is not valid hex" - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} ID constraint violations:") - for e in errors: - print(e) - elif self.verbose: - print("PASSED - All paraId/durableId values within constraints") - return not errors - - def validate_comment_markers(self): - errors = [] - - document_xml = None - comments_xml = None - for xml_file in self.xml_files: - if xml_file.name == "document.xml" and "word" in str(xml_file): - document_xml = xml_file - elif xml_file.name == "comments.xml": - comments_xml = xml_file - - if not document_xml: - if self.verbose: - print("PASSED - No document.xml found (skipping comment validation)") - return True - - try: - doc_root = lxml.etree.parse(str(document_xml)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - range_starts = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeStart", namespaces=namespaces - ) - } - range_ends = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeEnd", namespaces=namespaces - ) - } - references = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentReference", namespaces=namespaces - ) - } - - orphaned_ends = range_ends - range_starts - for comment_id in sorted( - orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' - ) - - orphaned_starts = range_starts - range_ends - for comment_id in sorted( - orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' - ) - - comment_ids = set() - if comments_xml and comments_xml.exists(): - comments_root = lxml.etree.parse(str(comments_xml)).getroot() - comment_ids = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in comments_root.xpath( - ".//w:comment", namespaces=namespaces - ) - } - - marker_ids = range_starts | range_ends | references - invalid_refs = marker_ids - comment_ids - for comment_id in sorted( - invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - if comment_id: - errors.append( - f' document.xml: marker id="{comment_id}" references non-existent comment' - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append(f" Error parsing XML: {e}") - - if errors: - print(f"FAILED - {len(errors)} comment marker violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All comment markers properly paired") - return True - - def repair(self) -> int: - repairs = super().repair() - repairs += self.repair_durableId() - return repairs - - def repair_durableId(self) -> int: - DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") - repairs = 0 - renames: dict = {} - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - is_numbering = xml_file.name == "numbering.xml" - base = 10 if is_numbering else 16 - pending = [] - seen_in_file = set() - modified = False - - for elem in dom.getElementsByTagName("*"): - for attr_name in DURABLE_ID_ATTRS: - if not elem.hasAttribute(attr_name): - continue - - durable_id = elem.getAttribute(attr_name) - try: - key = self._parse_id_value(durable_id, base=base) - needs_repair = key >= 0x7FFFFFFF - except ValueError: - key = durable_id - needs_repair = True - - if needs_repair: - if key in seen_in_file: - value = random.randint(1, 0x7FFFFFFE) - else: - seen_in_file.add(key) - if key not in renames: - renames[key] = random.randint(1, 0x7FFFFFFE) - value = renames[key] - new_id = str(value) if is_numbering else f"{value:08X}" - - elem.setAttribute(attr_name, new_id) - pending.append( - f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" - ) - modified = True - - if modified: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py deleted file mode 100644 index 7b53d0d3e4..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/pptx.py +++ /dev/null @@ -1,441 +0,0 @@ -""" -Validator for PowerPoint presentation XML files against XSD schemas. -""" - -import re -from pathlib import Path - -from helpers import opc_target, rels_source_part, safe_extract - -from .base import BaseSchemaValidator - - -class PPTXSchemaValidator(BaseSchemaValidator): - - PRESENTATIONML_NAMESPACE = ( - "http://schemas.openxmlformats.org/presentationml/2006/main" - ) - - ELEMENT_RELATIONSHIP_TYPES = { - "sldid": "slide", - "sldmasterid": "slidemaster", - "notesmasterid": "notesmaster", - "sldlayoutid": "slidelayout", - "themeid": "theme", - "tablestyleid": "tablestyles", - } - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_uuid_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_slide_layout_ids(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_notes_slide_references(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_no_duplicate_slide_layouts(): - all_valid = False - - if not self.validate_master_theme_uniqueness(): - all_valid = False - - if not self.validate_charts(): - all_valid = False - - if not self.validate_slides(): - all_valid = False - - return all_valid - - def _package_map(self) -> dict: - wanted = [] - wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) - wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) - wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) - for group in ("slideMasters", "notesMasters", "handoutMasters"): - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) - return { - p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() - for p in wanted - if p.is_file() - } - - def validate_master_theme_uniqueness(self): - from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes - - shared = live_shared_master_themes(self._package_map()) - if shared: - print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") - for message in shared: - print(f" {message}") - if any(m.startswith(_NOTES_MASTERS) for m in shared): - print(" Fix: in ppt/presentation.xml, move back to " - "directly after . PowerPoint reads that happily.") - else: - print(" Fix: give each master its own theme part.") - return False - - if self.verbose: - print("PASSED - No master shares a theme part in a way PowerPoint refuses") - return True - - def validate_charts(self): - from helpers.pptx_chart import find_chart_problems - - problems = find_chart_problems(self._package_map()) - if problems: - print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") - for message in problems: - print(f" {message}") - return False - - if self.verbose: - print("PASSED - Charts satisfy the constraints PowerPoint enforces") - return True - - def _original_slide_defects(self, schema) -> set[str]: - import tempfile - import zipfile - - from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors - - if self.original_file is None: - return set() - - found: set[str] = set() - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - try: - with zipfile.ZipFile(self.original_file, "r") as zf: - safe_extract(zf, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - for part in sorted(temp_path.rglob("*.xml")): - relative = part.relative_to(temp_path).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - part.resolve(), temp_path.resolve(), schema_path=schema - ) - if ok is None or ok or not errors: - continue - found |= set(fatal_slide_errors(set(errors))) - return found - - def validate_slides(self): - from helpers.pptx_slide import ( - SLIDE_PART_RE, - fatal_slide_errors, - is_schema_verdict, - ) - - schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] - inherited = self._original_slide_defects(schema) - problems: list[str] = [] - broken: list[str] = [] - - for xml_file in self.xml_files: - relative = xml_file.relative_to(self.unpacked_dir).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema - ) - if ok is None or not errors: - continue - - unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] - if unreadable: - broken.extend(unreadable) - continue - if ok: - continue - - for message in fatal_slide_errors(set(errors)): - if message in inherited: - continue - problems.append(f"{relative}: {message}") - - if broken: - print(f"FAILED - Could not check {len(broken)} slide part(s):") - for message in sorted(broken): - print(f" {message[:240]}") - - if problems: - print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") - for message in sorted(problems): - print(f" {message[:240]}") - - if broken or problems: - return False - - if self.verbose: - print("PASSED - Slide XML has none of the defects PowerPoint refuses") - return True - - def _get_schema_path(self, xml_file): - if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): - return None - return super()._get_schema_path(xml_file) - - def _preprocess_for_schema(self, xml_doc, relative_path): - if relative_path.as_posix() != "ppt/presentation.xml": - return xml_doc - - root = xml_doc.getroot() - ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" - notes = root.find(f"{ns}notesMasterIdLst") - slides = root.find(f"{ns}sldIdLst") - if notes is None or slides is None: - return xml_doc - - children = list(root) - if children.index(notes) < children.index(slides): - return xml_doc - - root.remove(notes) - root.insert(list(root).index(slides), notes) - return xml_doc - - def validate_uuid_ids(self): - import lxml.etree - - errors = [] - uuid_pattern = re.compile( - r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" - ) - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(): - for attr, value in elem.attrib.items(): - attr_name = attr.split("}")[-1].lower() - if attr_name == "id" or attr_name.endswith("id"): - if self._looks_like_uuid(value): - if not uuid_pattern.match(value): - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} UUID ID validation errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All UUID-like IDs contain valid hex values") - return True - - def _looks_like_uuid(self, value): - clean_value = value.strip("{}()").replace("-", "") - return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) - - def validate_slide_layout_ids(self): - import lxml.etree - - errors = [] - - slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) - - if not slide_masters: - if self.verbose: - print("PASSED - No slide masters found") - return True - - for slide_master in slide_masters: - try: - root = lxml.etree.parse(str(slide_master)).getroot() - - rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" - - if not rels_file.exists(): - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" - ) - continue - - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - valid_layout_rids = set() - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "slideLayout" in rel_type: - valid_layout_rids.add(rel.get("Id")) - - for sld_layout_id in root.findall( - f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" - ): - r_id = sld_layout_id.get( - f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" - ) - layout_id = sld_layout_id.get("id") - - if r_id and r_id not in valid_layout_rids: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " - f"references r:id='{r_id}' which is not found in slide layout relationships" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") - for error in errors: - print(error) - print( - "Remove invalid references or add missing slide layouts to the relationships file." - ) - return False - else: - if self.verbose: - print("PASSED - All slide layout IDs reference valid slide layouts") - return True - - def validate_no_duplicate_slide_layouts(self): - import lxml.etree - - errors = [] - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - layout_rels = [ - rel - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ) - if "slideLayout" in rel.get("Type", "") - ] - - if len(layout_rels) > 1: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" - ) - - except Exception as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print("FAILED - Found slides with duplicate slideLayout references:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All slides have exactly one slideLayout reference") - return True - - def validate_notes_slide_references(self): - import lxml.etree - - errors = [] - notes_slide_references = {} - - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - if not slide_rels_files: - if self.verbose: - print("PASSED - No slide relationship files found") - return True - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "notesSlide" in rel_type: - part = opc_target( - rel.get("Target", ""), - rels_source_part(rels_file, self.unpacked_dir), - rel.get("TargetMode", ""), - ) - if part: - slide_name = rels_file.stem.replace( - ".xml", "" - ) - - notes_slide_references.setdefault(part, []).append( - (slide_name, rels_file) - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - for target, references in notes_slide_references.items(): - if len(references) > 1: - slide_names = [ref[0] for ref in references] - errors.append( - f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" - ) - for slide_name, rels_file in references: - errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") - - if errors: - print( - f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" - ) - for error in errors: - print(error) - print("Each slide may optionally have its own slide file.") - return False - else: - if self.verbose: - print("PASSED - All notes slide references are unique") - return True - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py deleted file mode 100644 index 18d0c68be9..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/office/validators/redlining.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Validator for tracked changes in Word documents. - -Detects untracked edits in word/document.xml: text that differs from the -original without a / wrapper recording it. The tracked changes -that are new relative to the original are undone, and the result is compared -against the original; whatever text still differs was edited without being -tracked. - -Only the document body is compared. Headers, footers, footnotes and endnotes -are separate parts and are not checked. -""" - -import subprocess -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import rendered_text, safe_extract - - -class RedliningValidator: - - def __init__(self, unpacked_dir, original_docx, verbose=False): - self.unpacked_dir = Path(unpacked_dir) - self.original_docx = Path(original_docx) - self.verbose = verbose - self.namespaces = { - "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - } - - def repair(self) -> int: - return 0 - - def validate(self): - modified_file = self.unpacked_dir / "word" / "document.xml" - if not modified_file.exists(): - print(f"FAILED - Modified document.xml not found at {modified_file}") - return False - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_docx, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except Exception as e: - print(f"FAILED - Error unpacking original docx: {e}") - return False - - original_file = temp_path / "word" / "document.xml" - if not original_file.exists(): - print( - f"FAILED - Original document.xml not found in {self.original_docx}" - ) - return False - - try: - modified_tree = ET.parse(modified_file) - modified_root = modified_tree.getroot() - original_tree = ET.parse(original_file) - original_root = original_tree.getroot() - except (ET.ParseError, DefusedXmlException) as e: - print(f"FAILED - Error parsing XML files: {e}") - return False - - new_changes = self._new_tracked_changes(original_root, modified_root) - self._remove_tracked_changes(modified_root, new_changes) - - modified_text = self._extract_text_content(modified_root) - original_text = self._extract_text_content(original_root) - - if modified_text != original_text: - error_message = self._generate_detailed_diff( - original_text, modified_text - ) - print(error_message) - return False - - if self.verbose: - print( - f"PASSED - All {len(new_changes)} change(s) against the original " - "are properly tracked" - ) - return True - - def _tracked_change_elements(self, root): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] - - def _rendered_text(self, elem): - preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" - return rendered_text(elem.text or "", preserve) - - def _text_elements(self, elem): - w = self.namespaces["w"] - return [ - node - for node in elem.iter() - if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") - ] - - def _tracked_change_key(self, elem): - w = self.namespaces["w"] - text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) - return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) - - def _new_tracked_changes(self, original_root, modified_root): - original = self._tracked_change_elements(original_root) - modified = self._tracked_change_elements(modified_root) - - pool = {} - for elem in original: - pool.setdefault(self._tracked_change_key(elem), []).append(elem) - - matched, leftover = set(), [] - for elem in modified: - bucket = pool.get(self._tracked_change_key(elem)) - if bucket: - matched.add(bucket.pop()) - else: - leftover.append(elem) - - def group(elem): - return self._tracked_change_key(elem)[:3] - - def text_of(elems): - return "".join(self._tracked_change_key(e)[3] for e in elems) - - unmatched_original = {} - for elem in original: - if elem not in matched: - unmatched_original.setdefault(group(elem), []).append(elem) - - by_group = {} - for elem in leftover: - by_group.setdefault(group(elem), []).append(elem) - - new = set() - for key, elems in by_group.items(): - rebuilt = text_of(elems) - if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): - continue - new.update(elems) - return new - - def _generate_detailed_diff(self, original_text, modified_text): - error_parts = [ - "FAILED - Document text doesn't match after removing the tracked changes", - "", - "Likely causes:", - " 1. Modified text inside another author's or tags", - " 2. Made edits without proper tracked changes", - " 3. Didn't nest inside when deleting another's insertion", - " 4. Rewrote another author's / and changed its text on", - " the way. A tracked change from the original is recognised by its", - " author, date and text; anything that doesn't reproduce one exactly", - " reads as new, and the text it carried is reported missing.", - "", - "For pre-redlined documents, use correct patterns:", - " - To reject another's INSERTION: Nest inside their ", - " - To reject PART of one: nest around only the runs you reject.", - " Their may be split around it, so long as the pieces keep", - " their author and date and still spell out the same text.", - " - To restore another's DELETION: Add new AFTER their ", - "", - ] - - git_diff = self._get_git_word_diff(original_text, modified_text) - if git_diff: - error_parts.extend(["Differences:", "============", git_diff]) - else: - error_parts.append("Unable to generate word diff (git not available)") - - return "\n".join(error_parts) - - def _get_git_word_diff(self, original_text, modified_text): - try: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - original_file = temp_path / "original.txt" - modified_file = temp_path / "modified.txt" - - original_file.write_text(original_text, encoding="utf-8") - modified_file.write_text(modified_text, encoding="utf-8") - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "--word-diff-regex=.", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - - if content_lines: - return "\n".join(content_lines) - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - return "\n".join(content_lines) - - except (subprocess.CalledProcessError, FileNotFoundError, Exception): - pass - - return None - - def _remove_tracked_changes(self, root, targets): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - - for parent in root.iter(): - to_remove = [] - for child in parent: - if child.tag == ins_tag and child in targets: - to_remove.append(child) - for elem in to_remove: - parent.remove(elem) - - deltext_tag = f"{{{self.namespaces['w']}}}delText" - t_tag = f"{{{self.namespaces['w']}}}t" - - for parent in root.iter(): - to_process = [] - for child in parent: - if child.tag == del_tag and child in targets: - to_process.append((child, list(parent).index(child))) - - for del_elem, del_index in reversed(to_process): - for elem in del_elem.iter(): - if elem.tag == deltext_tag: - elem.tag = t_tag - - for child in reversed(list(del_elem)): - parent.insert(del_index, child) - parent.remove(del_elem) - - def _extract_text_content(self, root): - p_tag = f"{{{self.namespaces['w']}}}p" - t_tag = f"{{{self.namespaces['w']}}}t" - - paragraphs = [] - for p_elem in root.findall(f".//{p_tag}"): - text_parts = [] - for t_elem in p_elem.findall(f".//{t_tag}"): - text_parts.append(self._rendered_text(t_elem)) - paragraph_text = "".join(text_parts) - if paragraph_text: - paragraphs.append(paragraph_text) - - return "\n".join(paragraphs) - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py b/src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py deleted file mode 100755 index ae79b0e2fd..0000000000 --- a/src/crates/assembly/core/builtin_skills/pptx/scripts/thumbnail.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Create thumbnail grids from PowerPoint presentation slides. - -Creates a grid layout of slide thumbnails for quick visual analysis. -Labels each thumbnail with its XML filename (e.g., slide1.xml). -Hidden slides are shown with a placeholder pattern. - -Usage: - python thumbnail.py input.pptx [output_prefix] [--cols N] - -Examples: - python thumbnail.py presentation.pptx - # Creates: thumbnails.jpg - - python thumbnail.py template.pptx grid --cols 4 - # Creates: grid.jpg (or grid-1.jpg, grid-2.jpg for large decks) -""" - -import argparse -import posixpath -import subprocess -import sys -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom -from defusedxml import ElementTree -from office.helpers import SLIDE_REL_TYPE, opc_target -from office.soffice import run_soffice -from PIL import Image, ImageDraw, ImageFont - - -THUMBNAIL_WIDTH = 300 -CONVERSION_DPI = 100 -MAX_COLS = 6 -DEFAULT_COLS = 3 -JPEG_QUALITY = 95 -GRID_PADDING = 20 -BORDER_WIDTH = 2 -FONT_SIZE_RATIO = 0.10 -LABEL_PADDING_RATIO = 0.4 - - -def main(): - parser = argparse.ArgumentParser( - description="Create thumbnail grids from PowerPoint slides." - ) - parser.add_argument("input", help="Input PowerPoint file (.pptx)") - parser.add_argument( - "output_prefix", - nargs="?", - default="thumbnails", - help="Output prefix for image files (default: thumbnails)", - ) - parser.add_argument( - "--cols", - type=int, - default=DEFAULT_COLS, - help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})", - ) - - args = parser.parse_args() - - cols = min(args.cols, MAX_COLS) - if args.cols > MAX_COLS: - print(f"Warning: Columns limited to {MAX_COLS}") - - input_path = Path(args.input) - if not input_path.exists() or input_path.suffix.lower() != ".pptx": - print(f"Error: Invalid PowerPoint file: {args.input}", file=sys.stderr) - sys.exit(1) - - output_path = Path(f"{args.output_prefix}.jpg") - - try: - slide_info = get_slide_info(input_path) - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - visible_images = convert_to_images(input_path, temp_path) - - if not visible_images and not any(s["hidden"] for s in slide_info): - print("Error: No slides found", file=sys.stderr) - sys.exit(1) - - slides = build_slide_list(slide_info, visible_images, temp_path) - - grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH, output_path) - - print(f"Created {len(grid_files)} grid(s):") - for grid_file in grid_files: - print(f" {grid_file}") - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -def _is_hidden(zf: zipfile.ZipFile, part: str) -> bool: - try: - with zf.open(part) as f: - for _, root in ElementTree.iterparse(f, events=("start",)): - return root.get("show") in ("0", "false") - except (KeyError, ElementTree.ParseError): - return False - return False - - -def get_slide_info(pptx_path: Path) -> list[dict]: - with zipfile.ZipFile(pptx_path, "r") as zf: - rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8") - rels_dom = defusedxml.minidom.parseString(rels_content) - - rid_to_part = {} - for rel in rels_dom.getElementsByTagName("Relationship"): - if rel.getAttribute("Type") != SLIDE_REL_TYPE: - continue - part = opc_target( - rel.getAttribute("Target"), - "ppt/presentation.xml", - rel.getAttribute("TargetMode"), - ) - if part is not None: - rid_to_part[rel.getAttribute("Id")] = part - - pres_content = zf.read("ppt/presentation.xml").decode("utf-8") - pres_dom = defusedxml.minidom.parseString(pres_content) - - present = set(zf.namelist()) - - slides = [] - for sld_id in pres_dom.getElementsByTagName("p:sldId"): - part = rid_to_part.get(sld_id.getAttribute("r:id")) - if part is not None and part in present: - slides.append( - {"name": posixpath.basename(part), "hidden": _is_hidden(zf, part)} - ) - - return slides - - -def build_slide_list( - slide_info: list[dict], - visible_images: list[Path], - temp_dir: Path, -) -> list[tuple[Path, str]]: - visible_count = sum(1 for info in slide_info if not info["hidden"]) - rendered_hidden = len(visible_images) == len(slide_info) != visible_count - - if not rendered_hidden and visible_count != len(visible_images): - raise ValueError( - f"LibreOffice rendered {len(visible_images)} page(s) for {visible_count} " - f"visible slide(s) of {len(slide_info)}; thumbnails would be mislabeled" - ) - - if visible_images: - with Image.open(visible_images[0]) as img: - placeholder_size = img.size - else: - placeholder_size = (1920, 1080) - - slides = [] - visible_idx = 0 - - for info in slide_info: - if info["hidden"] and not rendered_hidden: - placeholder_path = temp_dir / f"hidden-{info['name']}.jpg" - placeholder_img = create_hidden_placeholder(placeholder_size) - placeholder_img.save(placeholder_path, "JPEG") - slides.append((placeholder_path, f"{info['name']} (hidden)")) - else: - label = f"{info['name']} (hidden)" if info["hidden"] else info["name"] - slides.append((visible_images[visible_idx], label)) - visible_idx += 1 - - return slides - - -def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image: - img = Image.new("RGB", size, color="#F0F0F0") - draw = ImageDraw.Draw(img) - line_width = max(5, min(size) // 100) - draw.line([(0, 0), size], fill="#CCCCCC", width=line_width) - draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width) - return img - - -def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]: - pdf_path = temp_dir / f"{pptx_path.stem}.pdf" - - result = run_soffice( - ["--headless", "--convert-to", "pdf", "--outdir", str(temp_dir), str(pptx_path)], - capture_output=True, - text=True, - ) - if result.returncode != 0 or not pdf_path.exists(): - detail = (result.stderr or result.stdout or "").strip() - raise RuntimeError(f"PDF conversion failed: {detail}" if detail else "PDF conversion failed") - - result = subprocess.run( - [ - "pdftoppm", - "-jpeg", - "-r", - str(CONVERSION_DPI), - str(pdf_path), - str(temp_dir / "slide"), - ], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise RuntimeError("Image conversion failed") - - return sorted(temp_dir.glob("slide-*.jpg")) - - -def create_grids( - slides: list[tuple[Path, str]], - cols: int, - width: int, - output_path: Path, -) -> list[str]: - max_per_grid = cols * (cols + 1) - grid_files = [] - - for chunk_idx, start_idx in enumerate(range(0, len(slides), max_per_grid)): - end_idx = min(start_idx + max_per_grid, len(slides)) - chunk_slides = slides[start_idx:end_idx] - - grid = create_grid(chunk_slides, cols, width) - - if len(slides) <= max_per_grid: - grid_filename = output_path - else: - stem = output_path.stem - suffix = output_path.suffix - grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}" - - grid_filename.parent.mkdir(parents=True, exist_ok=True) - grid.save(str(grid_filename), quality=JPEG_QUALITY) - grid_files.append(str(grid_filename)) - - return grid_files - - -def create_grid( - slides: list[tuple[Path, str]], - cols: int, - width: int, -) -> Image.Image: - font_size = int(width * FONT_SIZE_RATIO) - label_padding = int(font_size * LABEL_PADDING_RATIO) - - with Image.open(slides[0][0]) as img: - aspect = img.height / img.width - height = int(width * aspect) - - rows = (len(slides) + cols - 1) // cols - grid_w = cols * width + (cols + 1) * GRID_PADDING - grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING - - grid = Image.new("RGB", (grid_w, grid_h), "white") - draw = ImageDraw.Draw(grid) - - try: - font = ImageFont.load_default(size=font_size) - except Exception: - font = ImageFont.load_default() - - for i, (img_path, slide_name) in enumerate(slides): - row, col = i // cols, i % cols - x = col * width + (col + 1) * GRID_PADDING - y_base = ( - row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING - ) - - label = slide_name - bbox = draw.textbbox((0, 0), label, font=font) - text_w = bbox[2] - bbox[0] - draw.text( - (x + (width - text_w) // 2, y_base + label_padding), - label, - fill="black", - font=font, - ) - - y_thumbnail = y_base + label_padding + font_size + label_padding - - with Image.open(img_path) as img: - img.thumbnail((width, height), Image.Resampling.LANCZOS) - w, h = img.size - tx = x + (width - w) // 2 - ty = y_thumbnail + (height - h) // 2 - grid.paste(img, (tx, ty)) - - if BORDER_WIDTH > 0: - draw.rectangle( - [ - (tx - BORDER_WIDTH, ty - BORDER_WIDTH), - (tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1), - ], - outline="gray", - width=BORDER_WIDTH, - ) - - return grid - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/writing-skills/LICENSE.txt b/src/crates/assembly/core/builtin_skills/writing-skills/LICENSE.txt new file mode 100644 index 0000000000..abf0390320 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/writing-skills/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jesse Vincent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md b/src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md deleted file mode 100644 index 9f3f6ecfd9..0000000000 --- a/src/crates/assembly/core/builtin_skills/writing-skills/anthropic-best-practices.md +++ /dev/null @@ -1,1150 +0,0 @@ -# Skill authoring best practices - -> Learn how to write effective Skills that Claude can discover and use successfully. - -Good Skills are concise, well-structured, and tested with real usage. This guide provides practical authoring decisions to help you write Skills that Claude can discover and use effectively. - -For conceptual background on how Skills work, see the [Skills overview](/en/docs/agents-and-tools/agent-skills/overview). - -## Core principles - -### Concise is key - -The [context window](https://platform.claude.com/docs/en/build-with-claude/context-windows) is a public good. Your Skill shares the context window with everything else Claude needs to know, including: - -* The system prompt -* Conversation history -* Other Skills' metadata -* Your actual request - -Not every token in your Skill has an immediate cost. At startup, only the metadata (name and description) from all Skills is pre-loaded. Claude reads SKILL.md only when the Skill becomes relevant, and reads additional files only as needed. However, being concise in SKILL.md still matters: once Claude loads it, every token competes with conversation history and other context. - -**Default assumption**: Claude is already very smart - -Only add context Claude doesn't already have. Challenge each piece of information: - -* "Does Claude really need this explanation?" -* "Can I assume Claude knows this?" -* "Does this paragraph justify its token cost?" - -**Good example: Concise** (approximately 50 tokens): - -````markdown theme={null} -## Extract PDF text - -Use pdfplumber for text extraction: - -```python -import pdfplumber - -with pdfplumber.open("file.pdf") as pdf: - text = pdf.pages[0].extract_text() -``` -```` - -**Bad example: Too verbose** (approximately 150 tokens): - -```markdown theme={null} -## Extract PDF text - -PDF (Portable Document Format) files are a common file format that contains -text, images, and other content. To extract text from a PDF, you'll need to -use a library. There are many libraries available for PDF processing, but we -recommend pdfplumber because it's easy to use and handles most cases well. -First, you'll need to install it using pip. Then you can use the code below... -``` - -The concise version assumes Claude knows what PDFs are and how libraries work. - -### Set appropriate degrees of freedom - -Match the level of specificity to the task's fragility and variability. - -**High freedom** (text-based instructions): - -Use when: - -* Multiple approaches are valid -* Decisions depend on context -* Heuristics guide the approach - -Example: - -```markdown theme={null} -## Code review process - -1. Analyze the code structure and organization -2. Check for potential bugs or edge cases -3. Suggest improvements for readability and maintainability -4. Verify adherence to project conventions -``` - -**Medium freedom** (pseudocode or scripts with parameters): - -Use when: - -* A preferred pattern exists -* Some variation is acceptable -* Configuration affects behavior - -Example: - -````markdown theme={null} -## Generate report - -Use this template and customize as needed: - -```python -def generate_report(data, format="markdown", include_charts=True): - # Process data - # Generate output in specified format - # Optionally include visualizations -``` -```` - -**Low freedom** (specific scripts, few or no parameters): - -Use when: - -* Operations are fragile and error-prone -* Consistency is critical -* A specific sequence must be followed - -Example: - -````markdown theme={null} -## Database migration - -Run exactly this script: - -```bash -python scripts/migrate.py --verify --backup -``` - -Do not modify the command or add additional flags. -```` - -**Analogy**: Think of Claude as a robot exploring a path: - -* **Narrow bridge with cliffs on both sides**: There's only one safe way forward. Provide specific guardrails and exact instructions (low freedom). Example: database migrations that must run in exact sequence. -* **Open field with no hazards**: Many paths lead to success. Give general direction and trust Claude to find the best route (high freedom). Example: code reviews where context determines the best approach. - -### Test with all models you plan to use - -Skills act as additions to models, so effectiveness depends on the underlying model. Test your Skill with all the models you plan to use it with. - -**Testing considerations by model**: - -* **Claude Haiku** (fast, economical): Does the Skill provide enough guidance? -* **Claude Sonnet** (balanced): Is the Skill clear and efficient? -* **Claude Opus** (powerful reasoning): Does the Skill avoid over-explaining? - -What works perfectly for Opus might need more detail for Haiku. If you plan to use your Skill across multiple models, aim for instructions that work well with all of them. - -## Skill structure - - - **YAML Frontmatter**: The SKILL.md frontmatter requires two fields: - - * `name` - Human-readable name of the Skill (64 characters maximum) - * `description` - One-line description of what the Skill does and when to use it (1024 characters maximum) - - For complete Skill structure details, see the [Skills overview](/en/docs/agents-and-tools/agent-skills/overview#skill-structure). - - -### Naming conventions - -Use consistent naming patterns to make Skills easier to reference and discuss. We recommend using **gerund form** (verb + -ing) for Skill names, as this clearly describes the activity or capability the Skill provides. - -**Good naming examples (gerund form)**: - -* "Processing PDFs" -* "Analyzing spreadsheets" -* "Managing databases" -* "Testing code" -* "Writing documentation" - -**Acceptable alternatives**: - -* Noun phrases: "PDF Processing", "Spreadsheet Analysis" -* Action-oriented: "Process PDFs", "Analyze Spreadsheets" - -**Avoid**: - -* Vague names: "Helper", "Utils", "Tools" -* Overly generic: "Documents", "Data", "Files" -* Inconsistent patterns within your skill collection - -Consistent naming makes it easier to: - -* Reference Skills in documentation and conversations -* Understand what a Skill does at a glance -* Organize and search through multiple Skills -* Maintain a professional, cohesive skill library - -### Writing effective descriptions - -The `description` field enables Skill discovery and should include both what the Skill does and when to use it. - - - **Always write in third person**. The description is injected into the system prompt, and inconsistent point-of-view can cause discovery problems. - - * **Good:** "Processes Excel files and generates reports" - * **Avoid:** "I can help you process Excel files" - * **Avoid:** "You can use this to process Excel files" - - -**Be specific and include key terms**. Include both what the Skill does and specific triggers/contexts for when to use it. - -Each Skill has exactly one description field. The description is critical for skill selection: Claude uses it to choose the right Skill from potentially 100+ available Skills. Your description must provide enough detail for Claude to know when to select this Skill, while the rest of SKILL.md provides the implementation details. - -Effective examples: - -**PDF Processing skill:** - -```yaml theme={null} -description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction. -``` - -**Excel Analysis skill:** - -```yaml theme={null} -description: Analyze Excel spreadsheets, create pivot tables, generate charts. Use when analyzing Excel files, spreadsheets, tabular data, or .xlsx files. -``` - -**Git Commit Helper skill:** - -```yaml theme={null} -description: Generate descriptive commit messages by analyzing git diffs. Use when the user asks for help writing commit messages or reviewing staged changes. -``` - -Avoid vague descriptions like these: - -```yaml theme={null} -description: Helps with documents -``` - -```yaml theme={null} -description: Processes data -``` - -```yaml theme={null} -description: Does stuff with files -``` - -### Progressive disclosure patterns - -SKILL.md serves as an overview that points Claude to detailed materials as needed, like a table of contents in an onboarding guide. For an explanation of how progressive disclosure works, see [How Skills work](/en/docs/agents-and-tools/agent-skills/overview#how-skills-work) in the overview. - -**Practical guidance:** - -* Keep SKILL.md body under 500 lines for optimal performance -* Split content into separate files when approaching this limit -* Use the patterns below to organize instructions, code, and resources effectively - -#### Visual overview: From simple to complex - -A basic Skill starts with just a SKILL.md file containing metadata and instructions: - -Simple SKILL.md file showing YAML frontmatter and markdown body - -As your Skill grows, you can bundle additional content that Claude loads only when needed: - -Bundling additional reference files like reference.md and forms.md. - -The complete Skill directory structure might look like this: - -``` -pdf/ -├── SKILL.md # Main instructions (loaded when triggered) -├── FORMS.md # Form-filling guide (loaded as needed) -├── reference.md # API reference (loaded as needed) -├── examples.md # Usage examples (loaded as needed) -└── scripts/ - ├── analyze_form.py # Utility script (executed, not loaded) - ├── fill_form.py # Form filling script - └── validate.py # Validation script -``` - -#### Pattern 1: High-level guide with references - -````markdown theme={null} ---- -name: PDF Processing -description: Extracts text and tables from PDF files, fills forms, and merges documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction. ---- - -# PDF Processing - -## Quick start - -Extract text with pdfplumber: -```python -import pdfplumber -with pdfplumber.open("file.pdf") as pdf: - text = pdf.pages[0].extract_text() -``` - -## Advanced features - -**Form filling**: See [FORMS.md](FORMS.md) for complete guide -**API reference**: See [REFERENCE.md](REFERENCE.md) for all methods -**Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns -```` - -Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. - -#### Pattern 2: Domain-specific organization - -For Skills with multiple domains, organize content by domain to avoid loading irrelevant context. When a user asks about sales metrics, Claude only needs to read sales-related schemas, not finance or marketing data. This keeps token usage low and context focused. - -``` -bigquery-skill/ -├── SKILL.md (overview and navigation) -└── reference/ - ├── finance.md (revenue, billing metrics) - ├── sales.md (opportunities, pipeline) - ├── product.md (API usage, features) - └── marketing.md (campaigns, attribution) -``` - -````markdown SKILL.md theme={null} -# BigQuery Data Analysis - -## Available datasets - -**Finance**: Revenue, ARR, billing → See [reference/finance.md](reference/finance.md) -**Sales**: Opportunities, pipeline, accounts → See [reference/sales.md](reference/sales.md) -**Product**: API usage, features, adoption → See [reference/product.md](reference/product.md) -**Marketing**: Campaigns, attribution, email → See [reference/marketing.md](reference/marketing.md) - -## Quick search - -Find specific metrics using grep: - -```bash -grep -i "revenue" reference/finance.md -grep -i "pipeline" reference/sales.md -grep -i "api usage" reference/product.md -``` -```` - -#### Pattern 3: Conditional details - -Show basic content, link to advanced content: - -```markdown theme={null} -# DOCX Processing - -## Creating documents - -Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). - -## Editing documents - -For simple edits, modify the XML directly. - -**For tracked changes**: See [REDLINING.md](REDLINING.md) -**For OOXML details**: See [OOXML.md](OOXML.md) -``` - -Claude reads REDLINING.md or OOXML.md only when the user needs those features. - -### Avoid deeply nested references - -Claude may partially read files when they're referenced from other referenced files. When encountering nested references, Claude might use commands like `head -100` to preview content rather than reading entire files, resulting in incomplete information. - -**Keep references one level deep from SKILL.md**. All reference files should link directly from SKILL.md to ensure Claude reads complete files when needed. - -**Bad example: Too deep**: - -```markdown theme={null} -# SKILL.md -See [advanced.md](advanced.md)... - -# advanced.md -See [details.md](details.md)... - -# details.md -Here's the actual information... -``` - -**Good example: One level deep**: - -```markdown theme={null} -# SKILL.md - -**Basic usage**: [instructions in SKILL.md] -**Advanced features**: See [advanced.md](advanced.md) -**API reference**: See [reference.md](reference.md) -**Examples**: See [examples.md](examples.md) -``` - -### Structure longer reference files with table of contents - -For reference files longer than 100 lines, include a table of contents at the top. This ensures Claude can see the full scope of available information even when previewing with partial reads. - -**Example**: - -```markdown theme={null} -# API Reference - -## Contents -- Authentication and setup -- Core methods (create, read, update, delete) -- Advanced features (batch operations, webhooks) -- Error handling patterns -- Code examples - -## Authentication and setup -... - -## Core methods -... -``` - -Claude can then read the complete file or jump to specific sections as needed. - -For details on how this filesystem-based architecture enables progressive disclosure, see the [Runtime environment](#runtime-environment) section in the Advanced section below. - -## Workflows and feedback loops - -### Use workflows for complex tasks - -Break complex operations into clear, sequential steps. For particularly complex workflows, provide a checklist that Claude can copy into its response and check off as it progresses. - -**Example 1: Research synthesis workflow** (for Skills without code): - -````markdown theme={null} -## Research synthesis workflow - -Copy this checklist and track your progress: - -``` -Research Progress: -- [ ] Step 1: Read all source documents -- [ ] Step 2: Identify key themes -- [ ] Step 3: Cross-reference claims -- [ ] Step 4: Create structured summary -- [ ] Step 5: Verify citations -``` - -**Step 1: Read all source documents** - -Review each document in the `sources/` directory. Note the main arguments and supporting evidence. - -**Step 2: Identify key themes** - -Look for patterns across sources. What themes appear repeatedly? Where do sources agree or disagree? - -**Step 3: Cross-reference claims** - -For each major claim, verify it appears in the source material. Note which source supports each point. - -**Step 4: Create structured summary** - -Organize findings by theme. Include: -- Main claim -- Supporting evidence from sources -- Conflicting viewpoints (if any) - -**Step 5: Verify citations** - -Check that every claim references the correct source document. If citations are incomplete, return to Step 3. -```` - -This example shows how workflows apply to analysis tasks that don't require code. The checklist pattern works for any complex, multi-step process. - -**Example 2: PDF form filling workflow** (for Skills with code): - -````markdown theme={null} -## PDF form filling workflow - -Copy this checklist and check off items as you complete them: - -``` -Task Progress: -- [ ] Step 1: Analyze the form (run analyze_form.py) -- [ ] Step 2: Create field mapping (edit fields.json) -- [ ] Step 3: Validate mapping (run validate_fields.py) -- [ ] Step 4: Fill the form (run fill_form.py) -- [ ] Step 5: Verify output (run verify_output.py) -``` - -**Step 1: Analyze the form** - -Run: `python scripts/analyze_form.py input.pdf` - -This extracts form fields and their locations, saving to `fields.json`. - -**Step 2: Create field mapping** - -Edit `fields.json` to add values for each field. - -**Step 3: Validate mapping** - -Run: `python scripts/validate_fields.py fields.json` - -Fix any validation errors before continuing. - -**Step 4: Fill the form** - -Run: `python scripts/fill_form.py input.pdf fields.json output.pdf` - -**Step 5: Verify output** - -Run: `python scripts/verify_output.py output.pdf` - -If verification fails, return to Step 2. -```` - -Clear steps prevent Claude from skipping critical validation. The checklist helps both Claude and you track progress through multi-step workflows. - -### Implement feedback loops - -**Common pattern**: Run validator → fix errors → repeat - -This pattern greatly improves output quality. - -**Example 1: Style guide compliance** (for Skills without code): - -```markdown theme={null} -## Content review process - -1. Draft your content following the guidelines in STYLE_GUIDE.md -2. Review against the checklist: - - Check terminology consistency - - Verify examples follow the standard format - - Confirm all required sections are present -3. If issues found: - - Note each issue with specific section reference - - Revise the content - - Review the checklist again -4. Only proceed when all requirements are met -5. Finalize and save the document -``` - -This shows the validation loop pattern using reference documents instead of scripts. The "validator" is STYLE\_GUIDE.md, and Claude performs the check by reading and comparing. - -**Example 2: Document editing process** (for Skills with code): - -```markdown theme={null} -## Document editing process - -1. Make your edits to `word/document.xml` -2. **Validate immediately**: `python ooxml/scripts/validate.py unpacked_dir/` -3. If validation fails: - - Review the error message carefully - - Fix the issues in the XML - - Run validation again -4. **Only proceed when validation passes** -5. Rebuild: `python ooxml/scripts/pack.py unpacked_dir/ output.docx` -6. Test the output document -``` - -The validation loop catches errors early. - -## Content guidelines - -### Avoid time-sensitive information - -Don't include information that will become outdated: - -**Bad example: Time-sensitive** (will become wrong): - -```markdown theme={null} -If you're doing this before August 2025, use the old API. -After August 2025, use the new API. -``` - -**Good example** (use "old patterns" section): - -```markdown theme={null} -## Current method - -Use the v2 API endpoint: `api.example.com/v2/messages` - -## Old patterns - -
-Legacy v1 API (deprecated 2025-08) - -The v1 API used: `api.example.com/v1/messages` - -This endpoint is no longer supported. -
-``` - -The old patterns section provides historical context without cluttering the main content. - -### Use consistent terminology - -Choose one term and use it throughout the Skill: - -**Good - Consistent**: - -* Always "API endpoint" -* Always "field" -* Always "extract" - -**Bad - Inconsistent**: - -* Mix "API endpoint", "URL", "API route", "path" -* Mix "field", "box", "element", "control" -* Mix "extract", "pull", "get", "retrieve" - -Consistency helps Claude understand and follow instructions. - -## Common patterns - -### Template pattern - -Provide templates for output format. Match the level of strictness to your needs. - -**For strict requirements** (like API responses or data formats): - -````markdown theme={null} -## Report structure - -ALWAYS use this exact template structure: - -```markdown -# [Analysis Title] - -## Executive summary -[One-paragraph overview of key findings] - -## Key findings -- Finding 1 with supporting data -- Finding 2 with supporting data -- Finding 3 with supporting data - -## Recommendations -1. Specific actionable recommendation -2. Specific actionable recommendation -``` -```` - -**For flexible guidance** (when adaptation is useful): - -````markdown theme={null} -## Report structure - -Here is a sensible default format, but use your best judgment based on the analysis: - -```markdown -# [Analysis Title] - -## Executive summary -[Overview] - -## Key findings -[Adapt sections based on what you discover] - -## Recommendations -[Tailor to the specific context] -``` - -Adjust sections as needed for the specific analysis type. -```` - -### Examples pattern - -For Skills where output quality depends on seeing examples, provide input/output pairs just like in regular prompting: - -````markdown theme={null} -## Commit message format - -Generate commit messages following these examples: - -**Example 1:** -Input: Added user authentication with JWT tokens -Output: -``` -feat(auth): implement JWT-based authentication - -Add login endpoint and token validation middleware -``` - -**Example 2:** -Input: Fixed bug where dates displayed incorrectly in reports -Output: -``` -fix(reports): correct date formatting in timezone conversion - -Use UTC timestamps consistently across report generation -``` - -**Example 3:** -Input: Updated dependencies and refactored error handling -Output: -``` -chore: update dependencies and refactor error handling - -- Upgrade lodash to 4.17.21 -- Standardize error response format across endpoints -``` - -Follow this style: type(scope): brief description, then detailed explanation. -```` - -Examples help Claude understand the desired style and level of detail more clearly than descriptions alone. - -### Conditional workflow pattern - -Guide Claude through decision points: - -```markdown theme={null} -## Document modification workflow - -1. Determine the modification type: - - **Creating new content?** → Follow "Creation workflow" below - **Editing existing content?** → Follow "Editing workflow" below - -2. Creation workflow: - - Use docx-js library - - Build document from scratch - - Export to .docx format - -3. Editing workflow: - - Unpack existing document - - Modify XML directly - - Validate after each change - - Repack when complete -``` - - - If workflows become large or complicated with many steps, consider pushing them into separate files and tell Claude to read the appropriate file based on the task at hand. - - -## Evaluation and iteration - -### Build evaluations first - -**Create evaluations BEFORE writing extensive documentation.** This ensures your Skill solves real problems rather than documenting imagined ones. - -**Evaluation-driven development:** - -1. **Identify gaps**: Run Claude on representative tasks without a Skill. Document specific failures or missing context -2. **Create evaluations**: Build three scenarios that test these gaps -3. **Establish baseline**: Measure Claude's performance without the Skill -4. **Write minimal instructions**: Create just enough content to address the gaps and pass evaluations -5. **Iterate**: Execute evaluations, compare against baseline, and refine - -This approach ensures you're solving actual problems rather than anticipating requirements that may never materialize. - -**Evaluation structure**: - -```json theme={null} -{ - "skills": ["pdf-processing"], - "query": "Extract all text from this PDF file and save it to output.txt", - "files": ["test-files/document.pdf"], - "expected_behavior": [ - "Successfully reads the PDF file using an appropriate PDF processing library or command-line tool", - "Extracts text content from all pages in the document without missing any pages", - "Saves the extracted text to a file named output.txt in a clear, readable format" - ] -} -``` - - - This example demonstrates a data-driven evaluation with a simple testing rubric. We do not currently provide a built-in way to run these evaluations. Users can create their own evaluation system. Evaluations are your source of truth for measuring Skill effectiveness. - - -### Develop Skills iteratively with Claude - -The most effective Skill development process involves Claude itself. Work with one instance of Claude ("Claude A") to create a Skill that will be used by other instances ("Claude B"). Claude A helps you design and refine instructions, while Claude B tests them in real tasks. This works because Claude models understand both how to write effective agent instructions and what information agents need. - -**Creating a new Skill:** - -1. **Complete a task without a Skill**: Work through a problem with Claude A using normal prompting. As you work, you'll naturally provide context, explain preferences, and share procedural knowledge. Notice what information you repeatedly provide. - -2. **Identify the reusable pattern**: After completing the task, identify what context you provided that would be useful for similar future tasks. - - **Example**: If you worked through a BigQuery analysis, you might have provided table names, field definitions, filtering rules (like "always exclude test accounts"), and common query patterns. - -3. **Ask Claude A to create a Skill**: "Create a Skill that captures this BigQuery analysis pattern we just used. Include the table schemas, naming conventions, and the rule about filtering test accounts." - - - Claude models understand the Skill format and structure natively. You don't need special system prompts or a "writing skills" skill to get Claude to help create Skills. Simply ask Claude to create a Skill and it will generate properly structured SKILL.md content with appropriate frontmatter and body content. - - -4. **Review for conciseness**: Check that Claude A hasn't added unnecessary explanations. Ask: "Remove the explanation about what win rate means - Claude already knows that." - -5. **Improve information architecture**: Ask Claude A to organize the content more effectively. For example: "Organize this so the table schema is in a separate reference file. We might add more tables later." - -6. **Test on similar tasks**: Use the Skill with Claude B (a fresh instance with the Skill loaded) on related use cases. Observe whether Claude B finds the right information, applies rules correctly, and handles the task successfully. - -7. **Iterate based on observation**: If Claude B struggles or misses something, return to Claude A with specifics: "When Claude used this Skill, it forgot to filter by date for Q4. Should we add a section about date filtering patterns?" - -**Iterating on existing Skills:** - -The same hierarchical pattern continues when improving Skills. You alternate between: - -* **Working with Claude A** (the expert who helps refine the Skill) -* **Testing with Claude B** (the agent using the Skill to perform real work) -* **Observing Claude B's behavior** and bringing insights back to Claude A - -1. **Use the Skill in real workflows**: Give Claude B (with the Skill loaded) actual tasks, not test scenarios - -2. **Observe Claude B's behavior**: Note where it struggles, succeeds, or makes unexpected choices - - **Example observation**: "When I asked Claude B for a regional sales report, it wrote the query but forgot to filter out test accounts, even though the Skill mentions this rule." - -3. **Return to Claude A for improvements**: Share the current SKILL.md and describe what you observed. Ask: "I noticed Claude B forgot to filter test accounts when I asked for a regional report. The Skill mentions filtering, but maybe it's not prominent enough?" - -4. **Review Claude A's suggestions**: Claude A might suggest reorganizing to make rules more prominent, using stronger language like "MUST filter" instead of "always filter", or restructuring the workflow section. - -5. **Apply and test changes**: Update the Skill with Claude A's refinements, then test again with Claude B on similar requests - -6. **Repeat based on usage**: Continue this observe-refine-test cycle as you encounter new scenarios. Each iteration improves the Skill based on real agent behavior, not assumptions. - -**Gathering team feedback:** - -1. Share Skills with teammates and observe their usage -2. Ask: Does the Skill activate when expected? Are instructions clear? What's missing? -3. Incorporate feedback to address blind spots in your own usage patterns - -**Why this approach works**: Claude A understands agent needs, you provide domain expertise, Claude B reveals gaps through real usage, and iterative refinement improves Skills based on observed behavior rather than assumptions. - -### Observe how Claude navigates Skills - -As you iterate on Skills, pay attention to how Claude actually uses them in practice. Watch for: - -* **Unexpected exploration paths**: Does Claude read files in an order you didn't anticipate? This might indicate your structure isn't as intuitive as you thought -* **Missed connections**: Does Claude fail to follow references to important files? Your links might need to be more explicit or prominent -* **Overreliance on certain sections**: If Claude repeatedly reads the same file, consider whether that content should be in the main SKILL.md instead -* **Ignored content**: If Claude never accesses a bundled file, it might be unnecessary or poorly signaled in the main instructions - -Iterate based on these observations rather than assumptions. The 'name' and 'description' in your Skill's metadata are particularly critical. Claude uses these when deciding whether to trigger the Skill in response to the current task. Make sure they clearly describe what the Skill does and when it should be used. - -## Anti-patterns to avoid - -### Avoid Windows-style paths - -Always use forward slashes in file paths, even on Windows: - -* ✓ **Good**: `scripts/helper.py`, `reference/guide.md` -* ✗ **Avoid**: `scripts\helper.py`, `reference\guide.md` - -Unix-style paths work across all platforms, while Windows-style paths cause errors on Unix systems. - -### Avoid offering too many options - -Don't present multiple approaches unless necessary: - -````markdown theme={null} -**Bad example: Too many choices** (confusing): -"You can use pypdf, or pdfplumber, or PyMuPDF, or pdf2image, or..." - -**Good example: Provide a default** (with escape hatch): -"Use pdfplumber for text extraction: -```python -import pdfplumber -``` - -For scanned PDFs requiring OCR, use pdf2image with pytesseract instead." -```` - -## Advanced: Skills with executable code - -The sections below focus on Skills that include executable scripts. If your Skill uses only markdown instructions, skip to [Checklist for effective Skills](#checklist-for-effective-skills). - -### Solve, don't punt - -When writing scripts for Skills, handle error conditions rather than punting to Claude. - -**Good example: Handle errors explicitly**: - -```python theme={null} -def process_file(path): - """Process a file, creating it if it doesn't exist.""" - try: - with open(path) as f: - return f.read() - except FileNotFoundError: - # Create file with default content instead of failing - print(f"File {path} not found, creating default") - with open(path, 'w') as f: - f.write('') - return '' - except PermissionError: - # Provide alternative instead of failing - print(f"Cannot access {path}, using default") - return '' -``` - -**Bad example: Punt to Claude**: - -```python theme={null} -def process_file(path): - # Just fail and let Claude figure it out - return open(path).read() -``` - -Configuration parameters should also be justified and documented to avoid "voodoo constants" (Ousterhout's law). If you don't know the right value, how will Claude determine it? - -**Good example: Self-documenting**: - -```python theme={null} -# HTTP requests typically complete within 30 seconds -# Longer timeout accounts for slow connections -REQUEST_TIMEOUT = 30 - -# Three retries balances reliability vs speed -# Most intermittent failures resolve by the second retry -MAX_RETRIES = 3 -``` - -**Bad example: Magic numbers**: - -```python theme={null} -TIMEOUT = 47 # Why 47? -RETRIES = 5 # Why 5? -``` - -### Provide utility scripts - -Even if Claude could write a script, pre-made scripts offer advantages: - -**Benefits of utility scripts**: - -* More reliable than generated code -* Save tokens (no need to include code in context) -* Save time (no code generation required) -* Ensure consistency across uses - -Bundling executable scripts alongside instruction files - -The diagram above shows how executable scripts work alongside instruction files. The instruction file (forms.md) references the script, and Claude can execute it without loading its contents into context. - -**Important distinction**: Make clear in your instructions whether Claude should: - -* **Execute the script** (most common): "Run `analyze_form.py` to extract fields" -* **Read it as reference** (for complex logic): "See `analyze_form.py` for the field extraction algorithm" - -For most utility scripts, execution is preferred because it's more reliable and efficient. See the [Runtime environment](#runtime-environment) section below for details on how script execution works. - -**Example**: - -````markdown theme={null} -## Utility scripts - -**analyze_form.py**: Extract all form fields from PDF - -```bash -python scripts/analyze_form.py input.pdf > fields.json -``` - -Output format: -```json -{ - "field_name": {"type": "text", "x": 100, "y": 200}, - "signature": {"type": "sig", "x": 150, "y": 500} -} -``` - -**validate_boxes.py**: Check for overlapping bounding boxes - -```bash -python scripts/validate_boxes.py fields.json -# Returns: "OK" or lists conflicts -``` - -**fill_form.py**: Apply field values to PDF - -```bash -python scripts/fill_form.py input.pdf fields.json output.pdf -``` -```` - -### Use visual analysis - -When inputs can be rendered as images, have Claude analyze them: - -````markdown theme={null} -## Form layout analysis - -1. Convert PDF to images: - ```bash - python scripts/pdf_to_images.py form.pdf - ``` - -2. Analyze each page image to identify form fields -3. Claude can see field locations and types visually -```` - - - In this example, you'd need to write the `pdf_to_images.py` script. - - -Claude's vision capabilities help understand layouts and structures. - -### Create verifiable intermediate outputs - -When Claude performs complex, open-ended tasks, it can make mistakes. The "plan-validate-execute" pattern catches errors early by having Claude first create a plan in a structured format, then validate that plan with a script before executing it. - -**Example**: Imagine asking Claude to update 50 form fields in a PDF based on a spreadsheet. Without validation, Claude might reference non-existent fields, create conflicting values, miss required fields, or apply updates incorrectly. - -**Solution**: Use the workflow pattern shown above (PDF form filling), but add an intermediate `changes.json` file that gets validated before applying changes. The workflow becomes: analyze → **create plan file** → **validate plan** → execute → verify. - -**Why this pattern works:** - -* **Catches errors early**: Validation finds problems before changes are applied -* **Machine-verifiable**: Scripts provide objective verification -* **Reversible planning**: Claude can iterate on the plan without touching originals -* **Clear debugging**: Error messages point to specific problems - -**When to use**: Batch operations, destructive changes, complex validation rules, high-stakes operations. - -**Implementation tip**: Make validation scripts verbose with specific error messages like "Field 'signature\_date' not found. Available fields: customer\_name, order\_total, signature\_date\_signed" to help Claude fix issues. - -### Package dependencies - -Skills run in the code execution environment with platform-specific limitations: - -* **claude.ai**: Can install packages from npm and PyPI and pull from GitHub repositories -* **Anthropic API**: Has no network access and no runtime package installation - -List required packages in your SKILL.md and verify they're available in the [code execution tool documentation](/en/docs/agents-and-tools/tool-use/code-execution-tool). - -### Runtime environment - -Skills run in a code execution environment with filesystem access, bash commands, and code execution capabilities. For the conceptual explanation of this architecture, see [The Skills architecture](/en/docs/agents-and-tools/agent-skills/overview#the-skills-architecture) in the overview. - -**How this affects your authoring:** - -**How Claude accesses Skills:** - -1. **Metadata pre-loaded**: At startup, the name and description from all Skills' YAML frontmatter are loaded into the system prompt -2. **Files read on-demand**: Claude uses bash Read tools to access SKILL.md and other files from the filesystem when needed -3. **Scripts executed efficiently**: Utility scripts can be executed via bash without loading their full contents into context. Only the script's output consumes tokens -4. **No context penalty for large files**: Reference files, data, or documentation don't consume context tokens until actually read - -* **File paths matter**: Claude navigates your skill directory like a filesystem. Use forward slashes (`reference/guide.md`), not backslashes -* **Name files descriptively**: Use names that indicate content: `form_validation_rules.md`, not `doc2.md` -* **Organize for discovery**: Structure directories by domain or feature - * Good: `reference/finance.md`, `reference/sales.md` - * Bad: `docs/file1.md`, `docs/file2.md` -* **Bundle comprehensive resources**: Include complete API docs, extensive examples, large datasets; no context penalty until accessed -* **Prefer scripts for deterministic operations**: Write `validate_form.py` rather than asking Claude to generate validation code -* **Make execution intent clear**: - * "Run `analyze_form.py` to extract fields" (execute) - * "See `analyze_form.py` for the extraction algorithm" (read as reference) -* **Test file access patterns**: Verify Claude can navigate your directory structure by testing with real requests - -**Example:** - -``` -bigquery-skill/ -├── SKILL.md (overview, points to reference files) -└── reference/ - ├── finance.md (revenue metrics) - ├── sales.md (pipeline data) - └── product.md (usage analytics) -``` - -When the user asks about revenue, Claude reads SKILL.md, sees the reference to `reference/finance.md`, and invokes bash to read just that file. The sales.md and product.md files remain on the filesystem, consuming zero context tokens until needed. This filesystem-based model is what enables progressive disclosure. Claude can navigate and selectively load exactly what each task requires. - -For complete details on the technical architecture, see [How Skills work](/en/docs/agents-and-tools/agent-skills/overview#how-skills-work) in the Skills overview. - -### MCP tool references - -If your Skill uses MCP (Model Context Protocol) tools, always use fully qualified tool names to avoid "tool not found" errors. - -**Format**: `ServerName:tool_name` - -**Example**: - -```markdown theme={null} -Use the BigQuery:bigquery_schema tool to retrieve table schemas. -Use the GitHub:create_issue tool to create issues. -``` - -Where: - -* `BigQuery` and `GitHub` are MCP server names -* `bigquery_schema` and `create_issue` are the tool names within those servers - -Without the server prefix, Claude may fail to locate the tool, especially when multiple MCP servers are available. - -### Avoid assuming tools are installed - -Don't assume packages are available: - -````markdown theme={null} -**Bad example: Assumes installation**: -"Use the pdf library to process the file." - -**Good example: Explicit about dependencies**: -"Install required package: `pip install pypdf` - -Then use it: -```python -from pypdf import PdfReader -reader = PdfReader("file.pdf") -```" -```` - -## Technical notes - -### YAML frontmatter requirements - -The SKILL.md frontmatter requires `name` (64 characters max) and `description` (1024 characters max) fields. See the [Skills overview](/en/docs/agents-and-tools/agent-skills/overview#skill-structure) for complete structure details. - -### Token budgets - -Keep SKILL.md body under 500 lines for optimal performance. If your content exceeds this, split it into separate files using the progressive disclosure patterns described earlier. For architectural details, see the [Skills overview](/en/docs/agents-and-tools/agent-skills/overview#how-skills-work). - -## Checklist for effective Skills - -Before sharing a Skill, verify: - -### Core quality - -* [ ] Description is specific and includes key terms -* [ ] Description includes both what the Skill does and when to use it -* [ ] SKILL.md body is under 500 lines -* [ ] Additional details are in separate files (if needed) -* [ ] No time-sensitive information (or in "old patterns" section) -* [ ] Consistent terminology throughout -* [ ] Examples are concrete, not abstract -* [ ] File references are one level deep -* [ ] Progressive disclosure used appropriately -* [ ] Workflows have clear steps - -### Code and scripts - -* [ ] Scripts solve problems rather than punt to Claude -* [ ] Error handling is explicit and helpful -* [ ] No "voodoo constants" (all values justified) -* [ ] Required packages listed in instructions and verified as available -* [ ] Scripts have clear documentation -* [ ] No Windows-style paths (all forward slashes) -* [ ] Validation/verification steps for critical operations -* [ ] Feedback loops included for quality-critical tasks - -### Testing - -* [ ] At least three evaluations created -* [ ] Tested with Haiku, Sonnet, and Opus -* [ ] Tested with real usage scenarios -* [ ] Team feedback incorporated (if applicable) - -## Next steps - - - - Create your first Skill - - - - Create and manage Skills in Claude Code - - - - Upload and use Skills programmatically - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt b/src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt deleted file mode 100644 index c55ab42224..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -© 2025 Anthropic, PBC. All rights reserved. - -LICENSE: Use of these materials (including all code, prompts, assets, files, -and other components of this Skill) is governed by your agreement with -Anthropic regarding use of Anthropic's services. If no separate agreement -exists, use is governed by Anthropic's Consumer Terms of Service or -Commercial Terms of Service, as applicable: -https://www.anthropic.com/legal/consumer-terms -https://www.anthropic.com/legal/commercial-terms -Your applicable agreement is referred to as the "Agreement." "Services" are -as defined in the Agreement. - -ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the -contrary, users may not: - -- Extract these materials from the Services or retain copies of these - materials outside the Services -- Reproduce or copy these materials, except for temporary copies created - automatically during authorized use of the Services -- Create derivative works based on these materials -- Distribute, sublicense, or transfer these materials to any third party -- Make, offer to sell, sell, or import any inventions embodied in these - materials -- Reverse engineer, decompile, or disassemble these materials - -The receipt, viewing, or possession of these materials does not convey or -imply any license or right beyond those expressly granted above. - -Anthropic retains all right, title, and interest in these materials, -including all copyrights, patents, and other intellectual property rights. diff --git a/src/crates/assembly/core/builtin_skills/xlsx/SKILL.md b/src/crates/assembly/core/builtin_skills/xlsx/SKILL.md deleted file mode 100644 index dab8cacf95..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: xlsx -description: "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .xltx, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved." -license: Proprietary. LICENSE.txt has complete terms ---- - -# XLSX creation, editing, and analysis - -| Task | Approach | -|---|---| -| **Create** or **edit** with formulas/formatting | `openpyxl` — see gotchas below | -| **Bulk data** in or out | `pandas` (`read_excel`, `to_excel`) | -| **Quick look** at a sheet | `markitdown file.xlsx` — `## SheetName` per sheet; reads `.xlsm` too. No cell coordinates, so don't plan edits from it | -| **Read** a model (formulas *and* values) | two `load_workbook` passes — see gotchas | - -> `openpyxl`, `pandas`, and `markitdown` are preinstalled — do not run `pip install` first; write the script and import directly. Only if an import fails (or the `markitdown` command is missing): `pip install` the missing package. - -> Script paths below are relative to this skill's directory. - -## Requirements for every output - -- **Professional font** (Arial, Times New Roman) throughout, unless the user says otherwise. -- **Zero formula errors.** Never ship while `recalc.py` reports `errors_found`. If you think an error predates you, prove it: load the *original* with `data_only=True` and look at that cell. An error you introduced looks exactly like one you inherited. -- **Use formulas, never hardcoded results.** Write `sheet['B10'] = '=SUM(B2:B9)'`, not the Python-computed total. The sheet must recalculate when its inputs change. -- **Follow the user's spec literally.** Exact tab names, exact column headers, and the formula they spelled out. A redesign that computes something else fails, however elegant. -- **Document every assumption and hardcoded number** where the reader will see it — a cell comment, or an adjacent cell at a table's end. Cite a real source when one exists (`Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]`); when the number came from the user, say so plainly. -- **A workbook *you create* for someone to fill in** needs a short legend naming which cells to edit, and one example row of realistic values showing the expected format. Never add such a row to a file you were asked to edit. -- **Editing an existing file: match its conventions exactly.** They override every guideline here. Find its designated input cells first — a distinct font color, fill, or shading marks them — write only there, and leave every existing formula untouched. - -## Recalculate (mandatory whenever the file contains formulas) - -openpyxl writes formulas as strings with **no cached values**. Until you recalculate, every -formula cell reads back as `None` to anything reading cached values — `pandas`, -`load_workbook(data_only=True)`, and most previewers. - -```bash -python scripts/recalc.py output.xlsx [timeout_seconds] # default 30 -``` - -LibreOffice computes every formula, the file is **rewritten in place**, and you get JSON: -`status` (`success` | `errors_found`), `total_formulas`, `total_errors`, and an -`error_summary` naming up to 100 cells per error type (`locations_truncated` says how many it -withheld — trust `total_errors`, not the length of the list). Fix what it names and run it -again. **JSON with an `error` key instead of a `status` means nothing was recalculated**, and -only that case exits non-zero — `errors_found` exits 0, so never treat a clean exit as a clean -workbook. - -**A green recalc proves your formulas *evaluate*, not that they are *right*.** An off-by-one -range or a reference to the wrong row yields a clean, error-free file with wrong numbers. -Write 2–3 formulas first and check they pull the values you expect, before building out a grid. - -**A workbook that links to another file loses those links** if you re-save it with openpyxl and -then recalculate. Such a formula reads `='[1]Returns Analysis'!$B$2` — the `[1]` is an index -into the workbook's external-reference list, naming a *separate file on disk*, not a sheet. -That file is rarely present here, so the cell's cached value is the only thing holding its -data. openpyxl strips that value on save; LibreOffice then has to resolve the reference for -real, fails, writes `#NAME?`, and deletes every link. `recalc.py` refuses to run in that state -— copy those cells' values out of the original before you save over them (`--force` overrides, -and accepts the loss). - -## Choosing formulas that survive verification - -LibreOffice implements fewer functions than Excel, and one it cannot evaluate becomes a -literal `#NAME?` baked into the file you deliver. - -- **Prefer Excel-2007-era functions** — `SUMIFS`, `INDEX`, `MATCH`, `IFERROR`, `SUMPRODUCT` — which need no prefix. -- **Six post-2007 functions work, but only with an `_xlfn.` prefix**, because openpyxl writes your formula into the XML verbatim and Excel stores post-2007 names prefixed (its UI hides the prefix): `_xlfn.TEXTJOIN`, `_xlfn.CONCAT`, `_xlfn.IFS`, `_xlfn.SWITCH`, `_xlfn.MAXIFS`, `_xlfn.MINIFS`. Written bare, each yields `#NAME?`. -- **Never use `XLOOKUP`, `XMATCH`, `SORT`, `FILTER`, `UNIQUE`, or `SEQUENCE`.** The runtime's LibreOffice cannot evaluate them under *any* prefix. Newer builds do evaluate them, but they are spilling array functions and an openpyxl-written file has no spill metadata, so only the top-left cell of the range gets a value — and `recalc.py` reports `total_errors: 0` on the truncated result. Use `INDEX`/`MATCH` for lookups, and sort, filter, and de-duplicate in Python before writing the cells. -- A formula LibreOffice could not parse is written back **lowercased** — a quick tell beside a `#NAME?`. - -## openpyxl gotchas - -- **Reading a model takes two loads.** `data_only=True` yields cached values with the formulas gone; the default yields formula strings with no values. One pass cannot give you both. -- **`data_only=True` is destructive if you save.** That workbook has no formulas left, so saving replaces every one with a literal — permanently. -- **`data_only=True` on a file openpyxl just wrote returns `None` everywhere** — run `recalc.py` first. (A formula whose result is `""` also reads back as `None`.) -- **Merged cells: write the top-left anchor only.** Every other cell in the range is a `MergedCell` whose `.value` is read-only. -- **`.xlsm` loses its macros unless you pass `keep_vba=True`** to `load_workbook`. -- **A sheet name containing a space must be quoted** in a cross-sheet reference: `='Assumptions Inputs'!$B$5`. Unquoted, it evaluates to `#VALUE!`. - -## Financial models - -Unless the user says otherwise, or the existing file already does something else. - -**Color:** blue text (`0,0,255`) for hardcoded inputs and scenario levers · black for formulas · -green (`0,128,0`) for links to another sheet · red (`255,0,0`) for links to another file · -yellow fill (`255,255,0`) for key assumptions and cells the user should fill in. - -**Numbers:** currency `$#,##0`, with the unit named in the header (`Revenue ($mm)`) · zeros -render as `-`, including in percentages (`$#,##0;($#,##0);-`) · negatives in parentheses · -percentages `0.0%`, **stored as fractions** (`0.15` renders `15.0%`; storing `15` renders -`1500.0%`) · valuation multiples `0.0x` · years as text (`"2026"`, never `2,026`). - -**Structure:** every assumption in its own labeled cell, referenced by the formulas that use it -(`=B5*(1+$B$6)`, never `=B5*1.05`) · formulas consistent across every projection period, since a -lone edited cell mid-row is the commonest silent error · guard denominators that can be zero. - -## Dependencies - -`openpyxl`, `pandas`, `markitdown` (pip, preinstalled — install only if an import fails or the command is missing) · LibreOffice (`soffice`, auto-configured for sandboxed environments via `scripts/office/soffice.py`) diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py deleted file mode 100644 index 188b00aff4..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import posixpath -import re -import stat -import tempfile -import urllib.parse -import zipfile -from pathlib import Path - -OOXML_FAMILY = { - ".docx": "docx", - ".dotx": "docx", - ".pptx": "pptx", - ".potx": "pptx", - ".xlsx": "xlsx", - ".xltx": "xlsx", -} - -_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") - -SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" - -MAX_ARCHIVE_MEMBERS = 10_000 -MAX_ARCHIVE_MEMBER_SIZE = 1 * 1024 * 1024 * 1024 -MAX_ARCHIVE_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 -MAX_ARCHIVE_COMPRESSION_RATIO = 1_000 - - -def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None: - if not target: - return None - if target_mode.lower() == "external": - return None - if _SCHEME_RE.match(target): - return None - - target = urllib.parse.unquote(target) - - if "\\" in target: - raise ValueError(f"relationship target is not a POSIX part name: {target!r}") - - if target.startswith("/"): - joined = target.lstrip("/") - else: - joined = posixpath.join(posixpath.dirname(source_part), target) - - parts: list[str] = [] - for segment in posixpath.normpath(joined).split("/"): - if segment in ("", "."): - continue - if segment == "..": - if not parts: - raise ValueError(f"relationship target escapes the package: {target!r}") - parts.pop() - else: - parts.append(segment) - - if not parts: - raise ValueError(f"relationship target resolves to nothing: {target!r}") - return "/".join(parts) - - -def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str: - owner_dir = rels_file.parent.parent.relative_to(unpacked_dir) - return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./") - - -def part_text(data: bytes) -> str: - return data.decode("utf-8", "surrogateescape") - - -XML_SPACE = " \t\r\n" - - -def rendered_text(text: str, preserve: bool) -> str: - return text if preserve else text.strip(XML_SPACE) - - -def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None: - dest = dest.resolve() - members = zf.infolist() - if len(members) > MAX_ARCHIVE_MEMBERS: - raise ValueError(f"archive has too many entries: {len(members)}") - - total_size = 0 - targets: set[str] = set() - file_targets: set[str] = set() - validated: list[tuple[zipfile.ZipInfo, Path]] = [] - for m in members: - if stat.S_ISLNK(m.external_attr >> 16): - raise ValueError(f"symlink archive entry not allowed: {m.filename!r}") - target = (dest / m.filename).resolve() - if target == dest or not target.is_relative_to(dest): - raise ValueError(f"unsafe archive entry: {m.filename!r}") - target_key = os.path.normcase(str(target)) - if target_key in targets: - raise ValueError(f"duplicate archive entry: {m.filename!r}") - targets.add(target_key) - if not m.is_dir(): - file_targets.add(target_key) - validated.append((m, target)) - if m.file_size > MAX_ARCHIVE_MEMBER_SIZE: - raise ValueError(f"archive entry is too large: {m.filename!r}") - total_size += m.file_size - if total_size > MAX_ARCHIVE_TOTAL_SIZE: - raise ValueError("archive expands beyond the allowed total size") - if m.file_size and ( - m.compress_size == 0 - or m.file_size > m.compress_size * MAX_ARCHIVE_COMPRESSION_RATIO - ): - raise ValueError(f"archive entry has an unsafe compression ratio: {m.filename!r}") - - for m, target in validated: - for parent in target.parents: - if parent == dest: - break - if os.path.normcase(str(parent)) in file_targets: - raise ValueError(f"archive file entry conflicts with child path: {m.filename!r}") - - for m, _ in validated: - zf.extract(m, dest) - - -def rezip(src_dir: Path, out_path: Path) -> None: - files = sorted(p for p in src_dir.rglob("*") if p.is_file()) - ct = src_dir / "[Content_Types].xml" - fd, tmp_name = tempfile.mkstemp( - prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent - ) - tmp_out = Path(tmp_name) - try: - with os.fdopen(fd, "wb") as fh: - with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf: - if ct.exists(): - zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED) - for f in files: - if f == ct: - continue - zf.write(f, f.relative_to(src_dir)) - if out_path.exists(): - mode = out_path.stat().st_mode & 0o777 - else: - umask = os.umask(0) - os.umask(umask) - mode = 0o666 & ~umask - os.chmod(tmp_out, mode) - os.replace(tmp_out, out_path) - finally: - if tmp_out.exists(): - tmp_out.unlink() diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_chart.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_chart.py deleted file mode 100644 index 209cb7c58b..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_chart.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Find chart XML that PowerPoint refuses but the schema accepts. - -Detection only: for either fault more than one repair is valid, and only the -author knows which was meant. -""" - - -from __future__ import annotations - -import re -from typing import Mapping - -from . import part_text - - -_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml") - -_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") -_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""") - -def _strip_ext_lst(text: str) -> str: - out, cursor = [], 0 - for lo, hi in _ext_lst_spans(text): - out.append(text[cursor:lo]) - cursor = hi - out.append(text[cursor:]) - return "".join(out) - -_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"}) -ILLEGAL_ON_STACKED = frozenset({"outEnd"}) -LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase") - - -def _check_stacked_label_positions(part: str, xml: str) -> list[str]: - problems: list[str] = [] - for match in _BAR_GROUP_RE.finditer(xml): - block = _strip_ext_lst(match.group(0)) - group = match.group(1) - - grouping = _GROUPING_RE.search(block) - if grouping is None or grouping.group(1) not in STACKED_GROUPINGS: - continue - - bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED] - for pos in sorted(set(bad)): - problems.append( - f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a ' - f"{grouping.group(1)} {group}; PowerPoint allows only " - f"{', '.join(LEGAL_ON_STACKED)} there" - ) - return problems - - - -_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL) - -_AXID_RE = re.compile( - r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)""" -) - -_AXIS_DECL_RE = re.compile( - r"""]*(?\s*]*?\bval=["'](-?\d+)["']""" -) - -AXID_LIMIT = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3, - "surfaceChart": 3, "surface3DChart": 3, -} - -AXID_MINIMUM = { - "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2, - "bubbleChart": 2, "radarChart": 2, "stockChart": 2, - "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2, - "line3DChart": 3, "surface3DChart": 3, -} - - -def _declared_axes(xml: str) -> dict[str, list[str]]: - axes: dict[str, list[str]] = {} - for kind, axid in _AXIS_DECL_RE.findall(xml): - axes.setdefault(kind, []).append(axid) - return axes - - -def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None: - category = axes.get("catAx", []) + axes.get("dateAx", []) - value = axes.get("valAx", []) - series = axes.get("serAx", []) - if len(category) != 1 or len(value) != 1 or len(series) > 1: - return None - ids = [category[0], value[0]] - if limit >= 3 and series: - ids.append(series[0]) - return ids - - -def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None: - if kind not in AXID_LIMIT: - return None - ids = _AXID_RE.findall(block) - declared = {i for group in axes.values() for i in group} - if len([i for i in ids if i in declared]) >= 2: - return None - return ids - - -def _check_chart_axis_references(part: str, xml: str) -> list[str]: - axes = _declared_axes(xml) - problems: list[str] = [] - declared = {i for group in axes.values() for i in group} - for match in _ANY_CHART_GROUP_RE.finditer(xml): - kind, block = match.group(1), match.group(0) - ids = _undeclared_axes(kind, block, axes) - if ids is None: - continue - if not ids: - problems.append( - f"{part}: declares no this part can resolve; a chart " - f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer" - ) - continue - dead = [i for i in ids if i not in declared] - canonical = _canonical_ids(axes, AXID_LIMIT[kind]) - if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]: - hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})" - else: - hint = ("Fix: the part declares several axes of a kind -- declare the " - "secondary axes the series expects, or drop them") - detail = (f"of which {', '.join(dead)} name no declared axis" - if dead else f"only {len(ids)} of which this part declares") - problems.append( - f"{part}: references axId {', '.join(ids)}, {detail}, " - f"leaving fewer than two live axes; PowerPoint discards the chart. {hint}" - ) - return problems - - -def _ext_lst_spans(text: str) -> list[tuple[int, int]]: - spans: list[tuple[int, int]] = [] - depth = 0 - start = 0 - for match in re.finditer(r"<(/?)c:extLst\b[^>]*?(/?)>", text): - closing, self_closing = match.group(1), match.group(2) - if self_closing: - continue - if closing: - depth -= 1 - if depth == 0: - spans.append((start, match.end())) - else: - if depth == 0: - start = match.start() - depth += 1 - return spans - - -CHART_CHECKS = (_check_stacked_label_positions, _check_chart_axis_references) - - -def find_chart_problems(files: Mapping[str, bytes]) -> list[str]: - problems: list[str] = [] - for part in sorted(n for n in files if _CHART_PART_RE.fullmatch(n)): - xml = part_text(files[part]) - for check in CHART_CHECKS: - problems.extend(check(part, xml)) - return problems diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_slide.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_slide.py deleted file mode 100644 index 22f9aee0ff..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_slide.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Pick the slide-XML schema errors PowerPoint refuses the file over. - -A denylist over lxml's messages, so an unrecognised error class is a miss rather -than a false alarm. -""" - - -from __future__ import annotations - -import re - -SLIDE_PART_RE = re.compile( - r"ppt/(slides|slideLayouts|slideMasters|notesSlides|notesMasters|handoutMasters)" - r"/[^/]+\.xml" -) - -FATAL_SLIDE_ERRORS: tuple[tuple[re.Pattern[str], str], ...] = ( - ( - re.compile(r"\}tableStyleId': This element is not expected"), - "two in one (the schema allows one)", - ), - ( - re.compile(r"\}srgbClr', attribute 'val'"), - "a colour that is not six hex digits", - ), - ( - re.compile(r"\}txBody': Missing child element"), - "a with no children", - ), - ( - re.compile(r"\}miter', attribute 'lim'"), - 'a line join with lim="NaN"', - ), - ( - re.compile(r"\}uLnTx': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}overrideClrMapping': This element is not expected"), - " in a position the schema forbids", - ), - ( - re.compile(r"\}nvGrpSpPr': Missing child element"), - "a with no children", - ), -) - - -def is_schema_verdict(error: str) -> bool: - return error.startswith("Element ") - - -def fatal_slide_errors(errors: set[str]) -> list[str]: - out = [] - for error in sorted(errors): - for pattern, meaning in FATAL_SLIDE_ERRORS: - if pattern.search(error): - out.append(f"{meaning}: {error}") - break - return out diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_theme.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_theme.py deleted file mode 100644 index 5ef4c3e835..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/helpers/pptx_theme.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Find masters sharing a theme part in the way PowerPoint refuses to open. - -Reports only; the fix is to move back to directly after - in ppt/presentation.xml. -""" - - -from __future__ import annotations - -import posixpath -import re -from typing import Mapping - -from . import part_text - -THEME_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" - -_MASTER_RE = re.compile( - r"^ppt/(?PslideMasters|notesMasters|handoutMasters)/" - r"(?:slide|notes|handout)Master(?P\d+)\.xml$" -) -_GROUP_ORDER = {"slideMasters": 0, "notesMasters": 1, "handoutMasters": 2} - -_RELATIONSHIP_RE = re.compile( - r"]*?(?:/>|>.*?)", re.DOTALL -) - - -def _sort_key(name: str) -> tuple[int, int]: - m = _MASTER_RE.match(name) - assert m is not None - return (_GROUP_ORDER[m.group("group")], int(m.group("num"))) - - -def _rels_path(part: str) -> str: - directory, base = posixpath.split(part) - return f"{directory}/_rels/{base}.rels" - - -def _resolve(rels_path: str, target: str) -> str: - if target.startswith("/"): - return target.lstrip("/") - part_dir = posixpath.dirname(posixpath.dirname(rels_path)) - return posixpath.normpath(posixpath.join(part_dir, target)) - - -def _theme_rel(files: Mapping[str, bytes], master: str): - rels_path = _rels_path(master) - rels = files.get(rels_path) - if rels is None: - return None - for element in _RELATIONSHIP_RE.findall(part_text(rels)): - if f'Type="{THEME_REL_TYPE}"' not in element: - continue - target = re.search(r'\bTarget="([^"]+)"', element) - if target is None: - continue - return rels_path, element, _resolve(rels_path, target.group(1)) - return None - - -def _masters(files: Mapping[str, bytes]) -> list[str]: - return sorted((n for n in files if _MASTER_RE.match(n)), key=_sort_key) - - -_PRESENTATION = "ppt/presentation.xml" -_NOTES_MASTERS = "ppt/notesMasters/" -_IGNORABLE_RE = re.compile(r"|<\?.*?\?>", re.DOTALL) -_AFTER_SLDIDLST_RE = re.compile( - r"]*/>|[^>]*>.*?)\s*(<[^>\s/]+)", re.DOTALL -) - - -def _notes_master_share_is_inert(files: Mapping[str, bytes]) -> bool: - data = files.get(_PRESENTATION) - if data is None: - return False - match = _AFTER_SLDIDLST_RE.search(_IGNORABLE_RE.sub("", part_text(data))) - return match is not None and match.group(1) == " bool: - return inert_notes and master.startswith(_NOTES_MASTERS) - - -def find_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - ] - - -def live_shared_master_themes(files: Mapping[str, bytes]) -> list[str]: - inert_notes = _notes_master_share_is_inert(files) - return [ - f"{master} shares {theme} with {first}" - for master, _, _, theme, first in _shares(files) - if not _is_inert(master, inert_notes) - ] diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd deleted file mode 100644 index 6454ef9a94..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +++ /dev/null @@ -1,1499 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd deleted file mode 100644 index afa4f463e3..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd deleted file mode 100644 index 64e66b8abd..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +++ /dev/null @@ -1,1085 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd deleted file mode 100644 index 687eea8297..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd deleted file mode 100644 index 6ac81b06b7..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +++ /dev/null @@ -1,3081 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd deleted file mode 100644 index 1dbf05140d..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd deleted file mode 100644 index f1af17db4e..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd deleted file mode 100644 index 0a185ab6ed..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd deleted file mode 100644 index 14ef488865..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +++ /dev/null @@ -1,1676 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd deleted file mode 100644 index c20f3bf147..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd deleted file mode 100644 index ac60252262..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd deleted file mode 100644 index 424b8ba8d1..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd deleted file mode 100644 index 2bddce2921..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd deleted file mode 100644 index 8a8c18ba2d..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd deleted file mode 100644 index 5c42706a0d..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd deleted file mode 100644 index 853c341c87..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd deleted file mode 100644 index da835ee82d..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd deleted file mode 100644 index 87ad2658fa..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +++ /dev/null @@ -1,582 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd deleted file mode 100644 index 9e86f1b2be..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd deleted file mode 100644 index d0be42e757..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +++ /dev/null @@ -1,4439 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd deleted file mode 100644 index 8821dd183c..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +++ /dev/null @@ -1,570 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd deleted file mode 100644 index ca2575c753..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +++ /dev/null @@ -1,509 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd deleted file mode 100644 index dd079e603f..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd deleted file mode 100644 index 3dd6cf625a..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd deleted file mode 100644 index f1041e34ef..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd deleted file mode 100644 index 9c5b7a6334..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +++ /dev/null @@ -1,3646 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd deleted file mode 100644 index 0f13678d80..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - See http://www.w3.org/XML/1998/namespace.html and - http://www.w3.org/TR/REC-xml for information about this namespace. - - This schema document describes the XML namespace, in a form - suitable for import by other schema documents. - - Note that local names in this namespace are intended to be defined - only by the World Wide Web Consortium or its subgroups. The - following names are currently defined in this namespace and should - not be used with conflicting semantics by any Working Group, - specification, or document instance: - - base (as an attribute name): denotes an attribute whose value - provides a URI to be used as the base for interpreting any - relative URIs in the scope of the element on which it - appears; its value is inherited. This name is reserved - by virtue of its definition in the XML Base specification. - - lang (as an attribute name): denotes an attribute whose value - is a language code for the natural language of the content of - any element; its value is inherited. This name is reserved - by virtue of its definition in the XML specification. - - space (as an attribute name): denotes an attribute whose - value is a keyword indicating what whitespace processing - discipline is intended for the content of the element; its - value is inherited. This name is reserved by virtue of its - definition in the XML specification. - - Father (in any context at all): denotes Jon Bosak, the chair of - the original XML Working Group. This name is reserved by - the following decision of the W3C XML Plenary and - XML Coordination groups: - - In appreciation for his vision, leadership and dedication - the W3C XML Plenary on this 10th day of February, 2000 - reserves for Jon Bosak in perpetuity the XML name - xml:Father - - - - - This schema defines attributes and an attribute group - suitable for use by - schemas wishing to allow xml:base, xml:lang or xml:space attributes - on elements they define. - - To enable this, such a schema must import this schema - for the XML namespace, e.g. as follows: - <schema . . .> - . . . - <import namespace="http://www.w3.org/XML/1998/namespace" - schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> - - Subsequently, qualified reference to any of the attributes - or the group defined below will have the desired effect, e.g. - - <type . . .> - . . . - <attributeGroup ref="xml:specialAttrs"/> - - will define a type which will schema-validate an instance - element with any of those attributes - - - - In keeping with the XML Schema WG's standard versioning - policy, this schema document will persist at - http://www.w3.org/2001/03/xml.xsd. - At the date of issue it can also be found at - http://www.w3.org/2001/xml.xsd. - The schema document at that URI may however change in the future, - in order to remain compatible with the latest version of XML Schema - itself. In other words, if the XML Schema namespace changes, the version - of this document at - http://www.w3.org/2001/xml.xsd will change - accordingly; the version at - http://www.w3.org/2001/03/xml.xsd will not change. - - - - - - In due course, we should install the relevant ISO 2- and 3-letter - codes as the enumerated possible values . . . - - - - - - - - - - - - - - - See http://www.w3.org/TR/xmlbase/ for - information about this attribute. - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd deleted file mode 100644 index a6de9d2733..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd deleted file mode 100644 index 10e978b661..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd deleted file mode 100644 index 4248bf7a39..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd deleted file mode 100644 index 5649746712..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd deleted file mode 100644 index ef725457cf..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd deleted file mode 100644 index f65f777730..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +++ /dev/null @@ -1,560 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd deleted file mode 100644 index 6b00755a9a..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd deleted file mode 100644 index f321d333a5..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd deleted file mode 100644 index 364c6a9b8d..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd deleted file mode 100644 index fed9d15b7f..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd deleted file mode 100644 index 680cf15400..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd deleted file mode 100644 index 89ada90837..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py deleted file mode 100644 index 0b4c99deca..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/soffice.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Helper for running LibreOffice (soffice) in environments where AF_UNIX -sockets may be blocked (e.g., sandboxed VMs). Detects the restriction -at runtime and applies an LD_PRELOAD shim if needed. - -Usage: - from office.soffice import run_soffice - - result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) - -Call soffice through run_soffice, not through subprocess with get_soffice_env(): -the env dict carries the shim but names no user profile, and a non-root sandbox -cannot bootstrap the default one -- soffice aborts with "User installation could -not be completed" and converts nothing. get_soffice_env() stays public for the -callers that build their own argv (they must pass -env:UserInstallation too). -""" - -import contextlib -import os -import socket -import subprocess -import tempfile -from collections.abc import Iterable -from pathlib import Path - - -def get_soffice_env() -> dict: - env = os.environ.copy() - env["SAL_USE_VCLPLUGIN"] = "svp" - - if _needs_shim(): - shim = _ensure_shim() - env["LD_PRELOAD"] = str(shim) - - return env - - -def run_soffice(args: Iterable[str], **kwargs) -> subprocess.CompletedProcess: - args = list(args) - with contextlib.ExitStack() as stack: - if not any(str(a).startswith("-env:UserInstallation") for a in args): - profile = stack.enter_context( - tempfile.TemporaryDirectory(prefix="lo_profile_", ignore_cleanup_errors=True) - ) - args = [f"-env:UserInstallation={Path(profile).as_uri()}"] + args - return subprocess.run(["soffice"] + args, env=get_soffice_env(), **kwargs) - - - -_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" - - -def _needs_shim() -> bool: - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.close() - return False - except OSError: - return True - - -def _ensure_shim() -> Path: - if _SHIM_SO.exists(): - return _SHIM_SO - - src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" - src.write_text(_SHIM_SOURCE) - subprocess.run( - ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], - check=True, - capture_output=True, - ) - src.unlink() - return _SHIM_SO - - - -_SHIM_SOURCE = r""" -#define _GNU_SOURCE -#include -#include -#include -#include -#include -#include -#include - -static int (*real_socket)(int, int, int); -static int (*real_socketpair)(int, int, int, int[2]); -static int (*real_listen)(int, int); -static int (*real_accept)(int, struct sockaddr *, socklen_t *); -static int (*real_close)(int); -static int (*real_read)(int, void *, size_t); - -/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ -static int is_shimmed[1024]; -static int peer_of[1024]; -static int wake_r[1024]; /* accept() blocks reading this */ -static int wake_w[1024]; /* close() writes to this */ -static int listener_fd = -1; /* FD that received listen() */ - -__attribute__((constructor)) -static void init(void) { - real_socket = dlsym(RTLD_NEXT, "socket"); - real_socketpair = dlsym(RTLD_NEXT, "socketpair"); - real_listen = dlsym(RTLD_NEXT, "listen"); - real_accept = dlsym(RTLD_NEXT, "accept"); - real_close = dlsym(RTLD_NEXT, "close"); - real_read = dlsym(RTLD_NEXT, "read"); - for (int i = 0; i < 1024; i++) { - peer_of[i] = -1; - wake_r[i] = -1; - wake_w[i] = -1; - } -} - -/* ---- socket ---------------------------------------------------------- */ -int socket(int domain, int type, int protocol) { - if (domain == AF_UNIX) { - int fd = real_socket(domain, type, protocol); - if (fd >= 0) return fd; - /* socket(AF_UNIX) blocked – fall back to socketpair(). */ - int sv[2]; - if (real_socketpair(domain, type, protocol, sv) == 0) { - if (sv[0] >= 0 && sv[0] < 1024) { - is_shimmed[sv[0]] = 1; - peer_of[sv[0]] = sv[1]; - int wp[2]; - if (pipe(wp) == 0) { - wake_r[sv[0]] = wp[0]; - wake_w[sv[0]] = wp[1]; - } - } - return sv[0]; - } - errno = EPERM; - return -1; - } - return real_socket(domain, type, protocol); -} - -/* ---- listen ---------------------------------------------------------- */ -int listen(int sockfd, int backlog) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - listener_fd = sockfd; - return 0; - } - return real_listen(sockfd, backlog); -} - -/* ---- accept ---------------------------------------------------------- */ -int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { - if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { - /* Block until close() writes to the wake pipe. */ - if (wake_r[sockfd] >= 0) { - char buf; - real_read(wake_r[sockfd], &buf, 1); - } - errno = ECONNABORTED; - return -1; - } - return real_accept(sockfd, addr, addrlen); -} - -/* ---- close ----------------------------------------------------------- */ -int close(int fd) { - if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { - int was_listener = (fd == listener_fd); - is_shimmed[fd] = 0; - - if (wake_w[fd] >= 0) { /* unblock accept() */ - char c = 0; - write(wake_w[fd], &c, 1); - real_close(wake_w[fd]); - wake_w[fd] = -1; - } - if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } - if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } - - if (was_listener) - _exit(0); /* conversion done – exit */ - } - return real_close(fd); -} -""" - - - -if __name__ == "__main__": - import sys - result = run_soffice(sys.argv[1:]) - sys.exit(result.returncode) diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py deleted file mode 100755 index 8fbd2f71ca..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validate.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Command line tool to validate Office document XML files against XSD schemas and tracked changes. - -Usage: - python validate.py [--original ] [--auto-repair] [--author NAME] - -The first argument can be either: -- An unpacked directory containing the Office document XML files -- A packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx template) which will be unpacked to a temp directory - -Auto-repair fixes: -- paraId/durableId values that exceed OOXML limits -- Missing xml:space="preserve" on w:t elements with whitespace -""" - -import argparse -import sys -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import OOXML_FAMILY, rezip, safe_extract -from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator - -WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - - -def _fail(message: str): - print(f"Error: {message}", file=sys.stderr) - sys.exit(2) - - -def _has_tracked_changes(unpacked_dir: Path) -> bool: - document = unpacked_dir / "word" / "document.xml" - if not document.is_file(): - return False - try: - root = ET.parse(document).getroot() - except (ET.ParseError, DefusedXmlException): - return False - tracked = {f"{{{WORD_NS}}}ins", f"{{{WORD_NS}}}del"} - return any(elem.tag in tracked for elem in root.iter()) - - -def main(): - parser = argparse.ArgumentParser(description="Validate Office document XML files") - parser.add_argument( - "path", - help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx)", - ) - parser.add_argument( - "--original", - required=False, - default=None, - help="Path to original file (.docx/.pptx/.xlsx or .dotx/.potx/.xltx). If omitted, all XSD errors are reported and redlining validation is skipped.", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose output", - ) - parser.add_argument( - "--auto-repair", - action="store_true", - help="Automatically repair common issues (hex IDs, whitespace preservation). " - "Modifies the input in place: repairs to a packed file are written back to it.", - ) - parser.add_argument( - "--author", - default=None, - help="The name you are redlining under. Passing it turns on the " - "tracked-change check: any text differing from --original without a " - "/ recording it is reported. Untracked edits carry no " - "author, so the check covers them whoever made them — the name marks " - "the run as redlining work and is not used to filter. Requires " - "--original; docx only.", - ) - args = parser.parse_args() - - if args.author is not None and not args.original: - _fail("--author requires --original") - - path = Path(args.path) - if not path.exists(): - _fail(f"{path} does not exist") - - original_file = None - if args.original: - original_file = Path(args.original) - if not original_file.is_file(): - _fail(f"{original_file} is not a file") - if original_file.suffix.lower() not in OOXML_FAMILY: - _fail(f"{original_file} must be one of: {', '.join(sorted(OOXML_FAMILY))}") - - family = OOXML_FAMILY.get((original_file or path).suffix.lower()) - if family is None: - _fail( - f"Cannot determine file type from {path}. Use --original or provide one of: {', '.join(sorted(OOXML_FAMILY))}." - ) - - if args.author is not None and family != "docx": - _fail(f"--author only applies to docx files, not {family}") - - packed_file = None - temp_dir_ctx = None - if path.is_file() and path.suffix.lower() in OOXML_FAMILY: - packed_file = path - temp_dir_ctx = tempfile.TemporaryDirectory() - unpacked_dir = Path(temp_dir_ctx.name) - try: - with zipfile.ZipFile(path, "r") as zf: - safe_extract(zf, unpacked_dir) - except (zipfile.BadZipFile, ValueError, OSError) as e: - _fail(f"cannot unpack {path}: {e}") - else: - if not path.is_dir(): - _fail(f"{path} is not a directory or Office file") - unpacked_dir = path - - match family: - case "docx": - validators = [ - DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - if args.author is not None: - validators.append( - RedliningValidator(unpacked_dir, original_file, verbose=args.verbose) - ) - elif original_file and _has_tracked_changes(unpacked_dir): - print( - "Note: this document has tracked changes; they were not " - "checked against the original (pass --author to check)." - ) - case "pptx": - validators = [ - PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), - ] - case "xlsx": - exts = ", ".join(k for k, v in sorted(OOXML_FAMILY.items()) if v == "xlsx") - print( - f"No XSD schema validation is performed for xlsx-family files ({exts}). " - "For formula-error checking, use scripts/recalc.py instead." - ) - sys.exit(0) - case _: - print(f"Error: Validation not supported for file type {family}") - sys.exit(1) - - if args.auto_repair: - total_repairs = sum(v.repair() for v in validators) - if total_repairs: - print(f"Auto-repaired {total_repairs} issue(s)") - if packed_file is not None: - rezip(unpacked_dir, packed_file) - print(f"Wrote repaired file to {packed_file}") - - success = all([v.validate() for v in validators]) - - if temp_dir_ctx is not None: - temp_dir_ctx.cleanup() - - if success: - print("All validations PASSED!") - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py deleted file mode 100644 index db092ece7e..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Validation modules for Word document processing. -""" - -from .base import BaseSchemaValidator -from .docx import DOCXSchemaValidator -from .pptx import PPTXSchemaValidator -from .redlining import RedliningValidator - -__all__ = [ - "BaseSchemaValidator", - "DOCXSchemaValidator", - "PPTXSchemaValidator", - "RedliningValidator", -] diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py deleted file mode 100644 index 19d52a7fe0..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/base.py +++ /dev/null @@ -1,875 +0,0 @@ -""" -Base validator with common validation logic for document files. -""" - -import re -from pathlib import Path - -import defusedxml.minidom -from functools import lru_cache - -import lxml.etree - -from helpers import safe_extract - - -@lru_cache(maxsize=None) -def _load_schema(schema_path: str): - with open(schema_path, "rb") as xsd_file: - xsd_doc = lxml.etree.parse( - xsd_file, parser=lxml.etree.XMLParser(), base_url=schema_path - ) - return lxml.etree.XMLSchema(xsd_doc) - -class BaseSchemaValidator: - - IGNORED_VALIDATION_ERRORS = [ - "hyphenationZone", - "purl.org/dc/terms", - ] - - UNIQUE_ID_REQUIREMENTS = { - "comment": ("id", "file"), - "commentrangestart": ("id", "file"), - "commentrangeend": ("id", "file"), - "bookmarkstart": ("id", "file"), - "bookmarkend": ("id", "file"), - "sldid": ("id", "file"), - "sldmasterid": ("id", "global"), - "sldlayoutid": ("id", "global"), - "cm": ("authorid", "file"), - "sheet": ("sheetid", "file"), - "definedname": ("id", "file"), - "cxnsp": ("id", "file"), - "sp": ("id", "file"), - "pic": ("id", "file"), - "grpsp": ("id", "file"), - } - - EXCLUDED_ID_CONTAINERS = { - "sectionlst", - } - - ELEMENT_RELATIONSHIP_TYPES = {} - - SCHEMA_MAPPINGS = { - "word": "ISO-IEC29500-4_2016/wml.xsd", - "ppt": "ISO-IEC29500-4_2016/pml.xsd", - "xl": "ISO-IEC29500-4_2016/sml.xsd", - "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", - "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", - "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", - "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", - ".rels": "ecma/fouth-edition/opc-relationships.xsd", - "people.xml": "microsoft/wml-2012.xsd", - "commentsIds.xml": "microsoft/wml-cid-2016.xsd", - "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", - "commentsExtended.xml": "microsoft/wml-2012.xsd", - "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", - "theme": "ISO-IEC29500-4_2016/dml-main.xsd", - "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", - } - - MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" - XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" - - PACKAGE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/relationships" - ) - OFFICE_RELATIONSHIPS_NAMESPACE = ( - "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - ) - CONTENT_TYPES_NAMESPACE = ( - "http://schemas.openxmlformats.org/package/2006/content-types" - ) - - MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} - - OOXML_NAMESPACES = { - "http://schemas.openxmlformats.org/officeDocument/2006/math", - "http://schemas.openxmlformats.org/officeDocument/2006/relationships", - "http://schemas.openxmlformats.org/schemaLibrary/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/main", - "http://schemas.openxmlformats.org/drawingml/2006/chart", - "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/diagram", - "http://schemas.openxmlformats.org/drawingml/2006/picture", - "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", - "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", - "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "http://schemas.openxmlformats.org/presentationml/2006/main", - "http://schemas.openxmlformats.org/spreadsheetml/2006/main", - "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", - "http://www.w3.org/XML/1998/namespace", - } - - def __init__(self, unpacked_dir, original_file=None, verbose=False): - self.unpacked_dir = Path(unpacked_dir).resolve() - self.original_file = Path(original_file) if original_file else None - self.verbose = verbose - - self.schemas_dir = Path(__file__).parent.parent / "schemas" - - patterns = ["*.xml", "*.rels"] - self.xml_files = [ - f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) - ] - - if not self.xml_files: - print(f"Warning: No XML files found in {self.unpacked_dir}") - - def validate(self): - raise NotImplementedError("Subclasses must implement the validate method") - - def repair(self) -> int: - return self.repair_whitespace_preservation() - - def repair_whitespace_preservation(self) -> int: - repairs = 0 - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - pending = [] - - for elem in dom.getElementsByTagName("*"): - local_name = elem.tagName.rsplit(":", 1)[-1] - if local_name in ("t", "delText", "instrText", "delInstrText"): - text = "".join( - child.data - for child in elem.childNodes - if child.nodeType in (child.TEXT_NODE, child.CDATA_SECTION_NODE) - ) - ws = (" ", "\t", "\n", "\r") - if text and (text.startswith(ws) or text.endswith(ws)): - if elem.getAttribute("xml:space") != "preserve": - elem.setAttribute("xml:space", "preserve") - text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) - pending.append(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") - - if pending: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - def validate_xml(self): - errors = [] - - for xml_file in self.xml_files: - try: - lxml.etree.parse(str(xml_file)) - except lxml.etree.XMLSyntaxError as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {e.lineno}: {e.msg}" - ) - except Exception as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Unexpected error: {str(e)}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} XML violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All XML files are well-formed") - return True - - def validate_namespaces(self): - errors = [] - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - declared = set(root.nsmap.keys()) - {None} - - for attr_val in [ - v for k, v in root.attrib.items() if k.endswith("Ignorable") - ]: - undeclared = set(attr_val.split()) - declared - errors.extend( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Namespace '{ns}' in Ignorable but not declared" - for ns in undeclared - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} namespace issues:") - for error in errors: - print(error) - return False - if self.verbose: - print("PASSED - All namespace prefixes properly declared") - return True - - def validate_unique_ids(self): - errors = [] - global_ids = {} - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - file_ids = {} - - mc_elements = root.xpath( - ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} - ) - for elem in mc_elements: - elem.getparent().remove(elem) - - for elem in root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag = ( - elem.tag.split("}")[-1].lower() - if "}" in elem.tag - else elem.tag.lower() - ) - - if tag in self.UNIQUE_ID_REQUIREMENTS: - in_excluded_container = any( - ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS - for ancestor in elem.iterancestors() - ) - if in_excluded_container: - continue - - attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] - - id_value = None - for attr, value in elem.attrib.items(): - attr_local = ( - attr.split("}")[-1].lower() - if "}" in attr - else attr.lower() - ) - if attr_local == attr_name: - id_value = value - break - - if id_value is not None: - if scope == "global": - if id_value in global_ids: - prev_file, prev_line, prev_tag = global_ids[ - id_value - ] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " - f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" - ) - else: - global_ids[id_value] = ( - xml_file.relative_to(self.unpacked_dir), - elem.sourceline, - tag, - ) - elif scope == "file": - key = (tag, attr_name) - if key not in file_ids: - file_ids[key] = {} - - if id_value in file_ids[key]: - prev_line = file_ids[key][id_value] - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " - f"(first occurrence at line {prev_line})" - ) - else: - file_ids[key][id_value] = elem.sourceline - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} ID uniqueness violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All required IDs are unique") - return True - - def validate_file_references(self): - errors = [] - - rels_files = list(self.unpacked_dir.rglob("*.rels")) - - if not rels_files: - if self.verbose: - print("PASSED - No .rels files found") - return True - - all_files = [] - for file_path in self.unpacked_dir.rglob("*"): - if ( - file_path.is_file() - and file_path.name != "[Content_Types].xml" - and not file_path.name.endswith(".rels") - ): - all_files.append(file_path.resolve()) - - all_referenced_files = set() - - if self.verbose: - print( - f"Found {len(rels_files)} .rels files and {len(all_files)} target files" - ) - - for rels_file in rels_files: - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - rels_dir = rels_file.parent - - referenced_files = set() - broken_refs = [] - - for rel in rels_root.findall( - ".//ns:Relationship", - namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, - ): - target = rel.get("Target") - if rel.get("TargetMode") == "External": - continue - if target and not target.startswith( - ("http", "mailto:") - ): - if target.startswith("/"): - target_path = self.unpacked_dir / target.lstrip("/") - elif rels_file.name == ".rels": - target_path = self.unpacked_dir / target - else: - base_dir = rels_dir.parent - target_path = base_dir / target - - try: - target_path = target_path.resolve() - if target_path.exists() and target_path.is_file(): - referenced_files.add(target_path) - all_referenced_files.add(target_path) - else: - broken_refs.append((target, rel.sourceline)) - except (OSError, ValueError): - broken_refs.append((target, rel.sourceline)) - - if broken_refs: - rel_path = rels_file.relative_to(self.unpacked_dir) - for broken_ref, line_num in broken_refs: - errors.append( - f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" - ) - - except Exception as e: - rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append(f" Error parsing {rel_path}: {e}") - - unreferenced_files = set(all_files) - all_referenced_files - - if unreferenced_files: - for unref_file in sorted(unreferenced_files): - unref_rel_path = unref_file.relative_to(self.unpacked_dir) - errors.append(f" Unreferenced file: {unref_rel_path}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship validation errors:") - for error in errors: - print(error) - print( - "CRITICAL: These errors will cause the document to appear corrupt. " - + "Broken references MUST be fixed, " - + "and unreferenced files MUST be referenced or removed." - ) - return False - else: - if self.verbose: - print( - "PASSED - All references are valid and all files are properly referenced" - ) - return True - - def validate_all_relationship_ids(self): - import lxml.etree - - errors = [] - - for xml_file in self.xml_files: - if xml_file.suffix == ".rels": - continue - - rels_dir = xml_file.parent / "_rels" - rels_file = rels_dir / f"{xml_file.name}.rels" - - if not rels_file.exists(): - continue - - try: - rels_root = lxml.etree.parse(str(rels_file)).getroot() - rid_to_type = {} - - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rid = rel.get("Id") - rel_type = rel.get("Type", "") - if rid: - if rid in rid_to_type: - rels_rel_path = rels_file.relative_to(self.unpacked_dir) - errors.append( - f" {rels_rel_path}: Line {rel.sourceline}: " - f"Duplicate relationship ID '{rid}' (IDs must be unique)" - ) - type_name = ( - rel_type.split("/")[-1] if "/" in rel_type else rel_type - ) - rid_to_type[rid] = type_name - - xml_root = lxml.etree.parse(str(xml_file)).getroot() - - r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE - rid_attrs_to_check = ["id", "embed", "link"] - for elem in xml_root.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - for attr_name in rid_attrs_to_check: - rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") - if not rid_attr: - continue - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - elem_name = ( - elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag - ) - - if rid_attr not in rid_to_type: - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " - f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" - ) - elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: - expected_type = self._get_expected_relationship_type( - elem_name - ) - if expected_type: - actual_type = rid_to_type[rid_attr] - if expected_type not in actual_type.lower(): - errors.append( - f" {xml_rel_path}: Line {elem.sourceline}: " - f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " - f"but should point to a '{expected_type}' relationship" - ) - - except Exception as e: - xml_rel_path = xml_file.relative_to(self.unpacked_dir) - errors.append(f" Error processing {xml_rel_path}: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} relationship ID reference errors:") - for error in errors: - print(error) - print("\nThese ID mismatches will cause the document to appear corrupt!") - return False - else: - if self.verbose: - print("PASSED - All relationship ID references are valid") - return True - - def _get_expected_relationship_type(self, element_name): - elem_lower = element_name.lower() - - if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: - return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] - - if elem_lower.endswith("id") and len(elem_lower) > 2: - prefix = elem_lower[:-2] - if prefix.endswith("master"): - return prefix.lower() - elif prefix.endswith("layout"): - return prefix.lower() - else: - if prefix == "sld": - return "slide" - return prefix.lower() - - if elem_lower.endswith("reference") and len(elem_lower) > 9: - prefix = elem_lower[:-9] - return prefix.lower() - - return None - - def validate_content_types(self): - errors = [] - - content_types_file = self.unpacked_dir / "[Content_Types].xml" - if not content_types_file.exists(): - print("FAILED - [Content_Types].xml file not found") - return False - - try: - root = lxml.etree.parse(str(content_types_file)).getroot() - declared_parts = set() - declared_extensions = set() - - for override in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" - ): - part_name = override.get("PartName") - if part_name is not None: - declared_parts.add(part_name.lstrip("/")) - - for default in root.findall( - f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" - ): - extension = default.get("Extension") - if extension is not None: - declared_extensions.add(extension.lower()) - - declarable_roots = { - "sld", - "sldLayout", - "sldMaster", - "presentation", - "document", - "workbook", - "worksheet", - "theme", - } - - media_extensions = { - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - "bmp": "image/bmp", - "tiff": "image/tiff", - "wmf": "image/x-wmf", - "emf": "image/x-emf", - } - - all_files = list(self.unpacked_dir.rglob("*")) - all_files = [f for f in all_files if f.is_file()] - - for xml_file in self.xml_files: - path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( - "\\", "/" - ) - - if any( - skip in path_str - for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] - ): - continue - - try: - root_tag = lxml.etree.parse(str(xml_file)).getroot().tag - root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag - - if root_name in declarable_roots and path_str not in declared_parts: - errors.append( - f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" - ) - - except Exception: - continue - - for file_path in all_files: - if file_path.suffix.lower() in {".xml", ".rels"}: - continue - if file_path.name == "[Content_Types].xml": - continue - if "_rels" in file_path.parts or "docProps" in file_path.parts: - continue - - extension = file_path.suffix.lstrip(".").lower() - if extension and extension not in declared_extensions: - if extension in media_extensions: - relative_path = file_path.relative_to(self.unpacked_dir) - errors.append( - f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' - ) - - except Exception as e: - errors.append(f" Error parsing [Content_Types].xml: {e}") - - if errors: - print(f"FAILED - Found {len(errors)} content type declaration errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print( - "PASSED - All content files are properly declared in [Content_Types].xml" - ) - return True - - def validate_file_against_xsd(self, xml_file, verbose=False): - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - - is_valid, current_errors = self._validate_single_file_xsd( - xml_file, unpacked_dir - ) - - if is_valid is None: - return None, set() - elif is_valid: - return True, set() - - original_errors = self._get_original_file_errors(xml_file) - - assert current_errors is not None - new_errors = current_errors - original_errors - - new_errors = { - e for e in new_errors - if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) - } - - if new_errors: - if verbose: - relative_path = xml_file.relative_to(unpacked_dir) - print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") - for error in list(new_errors)[:3]: - truncated = error[:250] + "..." if len(error) > 250 else error - print(f" - {truncated}") - return False, new_errors - else: - if verbose: - print( - f"PASSED - No new errors (original had {len(current_errors)} errors)" - ) - return True, set() - - def validate_against_xsd(self): - new_errors = [] - original_error_count = 0 - valid_count = 0 - skipped_count = 0 - - for xml_file in self.xml_files: - relative_path = str(xml_file.relative_to(self.unpacked_dir)) - is_valid, new_file_errors = self.validate_file_against_xsd( - xml_file, verbose=False - ) - - if is_valid is None: - skipped_count += 1 - continue - elif is_valid and not new_file_errors: - valid_count += 1 - continue - elif is_valid: - original_error_count += 1 - valid_count += 1 - continue - - new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") - for error in list(new_file_errors)[:3]: - new_errors.append( - f" - {error[:250]}..." if len(error) > 250 else f" - {error}" - ) - - if self.verbose: - print(f"Validated {len(self.xml_files)} files:") - print(f" - Valid: {valid_count}") - print(f" - Skipped (no schema): {skipped_count}") - if original_error_count: - print(f" - With original errors (ignored): {original_error_count}") - print( - f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" - ) - - if new_errors: - print("\nFAILED - Found NEW validation errors:") - for error in new_errors: - print(error) - return False - else: - if self.verbose: - print("\nPASSED - No new XSD validation errors introduced") - return True - - def _get_schema_path(self, xml_file): - if xml_file.name in self.SCHEMA_MAPPINGS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] - - if xml_file.suffix == ".rels": - return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] - - if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] - - if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): - return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] - - if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: - return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] - - return None - - def _clean_ignorable_namespaces(self, xml_doc): - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - for elem in xml_copy.iter(): - attrs_to_remove = [] - - for attr in elem.attrib: - if "{" in attr: - ns = attr.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - attrs_to_remove.append(attr) - - for attr in attrs_to_remove: - del elem.attrib[attr] - - self._remove_ignorable_elements(xml_copy) - - return lxml.etree.ElementTree(xml_copy) - - def _remove_ignorable_elements(self, root): - elements_to_remove = [] - - for elem in list(root): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - - tag_str = str(elem.tag) - if tag_str.startswith("{"): - ns = tag_str.split("}")[0][1:] - if ns not in self.OOXML_NAMESPACES: - elements_to_remove.append(elem) - continue - - self._remove_ignorable_elements(elem) - - for elem in elements_to_remove: - root.remove(elem) - - def _preprocess_for_mc_ignorable(self, xml_doc): - root = xml_doc.getroot() - - if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: - del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] - - return xml_doc - - def _preprocess_for_schema(self, xml_doc, relative_path): - return xml_doc - - def _validate_single_file_xsd(self, xml_file, base_path, schema_path=None): - schema_path = schema_path or self._get_schema_path(xml_file) - if not schema_path: - return None, None - - try: - schema = _load_schema(str(schema_path)) - - with open(xml_file, "r") as f: - xml_doc = lxml.etree.parse(f) - - xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) - xml_doc = self._preprocess_for_mc_ignorable(xml_doc) - - relative_path = xml_file.relative_to(base_path) - if ( - relative_path.parts - and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS - ): - xml_doc = self._clean_ignorable_namespaces(xml_doc) - - xml_doc = self._preprocess_for_schema(xml_doc, relative_path) - - if schema.validate(xml_doc): - return True, set() - else: - errors = set() - for error in schema.error_log: - errors.add(error.message) - return False, errors - - except Exception as e: - return False, {str(e)} - - def _get_original_file_errors(self, xml_file, schema_path=None): - if self.original_file is None: - return set() - - import tempfile - import zipfile - - xml_file = Path(xml_file).resolve() - unpacked_dir = self.unpacked_dir.resolve() - relative_path = xml_file.relative_to(unpacked_dir) - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_file, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - original_xml_file = temp_path / relative_path - - if not original_xml_file.exists(): - return set() - - is_valid, errors = self._validate_single_file_xsd( - original_xml_file, temp_path, schema_path=schema_path - ) - return errors if errors else set() - - def _remove_template_tags_from_text_nodes(self, xml_doc): - warnings = [] - template_pattern = re.compile(r"\{\{[^}]*\}\}") - - xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") - xml_copy = lxml.etree.fromstring(xml_string) - - def process_text_content(text, content_type): - if not text: - return text - matches = list(template_pattern.finditer(text)) - if matches: - for match in matches: - warnings.append( - f"Found template tag in {content_type}: {match.group()}" - ) - return template_pattern.sub("", text) - return text - - for elem in xml_copy.iter(): - if not hasattr(elem, "tag") or callable(elem.tag): - continue - tag_str = str(elem.tag) - if tag_str.endswith("}t") or tag_str == "t": - continue - - elem.text = process_text_content(elem.text, "text content") - elem.tail = process_text_content(elem.tail, "tail content") - - return lxml.etree.ElementTree(xml_copy), warnings - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py deleted file mode 100644 index 0d18b6979a..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/docx.py +++ /dev/null @@ -1,466 +0,0 @@ -""" -Validator for Word document XML files against XSD schemas. -""" - -import random -import re -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.minidom -import lxml.etree - -from helpers import safe_extract - -from .base import BaseSchemaValidator - - -class DOCXSchemaValidator(BaseSchemaValidator): - - WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" - W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" - - ELEMENT_RELATIONSHIP_TYPES = {} - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_whitespace_preservation(): - all_valid = False - - if not self.validate_deletions(): - all_valid = False - - if not self.validate_insertions(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_id_constraints(): - all_valid = False - - if not self.validate_comment_markers(): - all_valid = False - - self.compare_paragraph_counts() - - return all_valid - - def validate_whitespace_preservation(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): - if elem.text: - text = elem.text - if re.search(r"^[ \t\n\r]", text) or re.search( - r"[ \t\n\r]$", text - ): - xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" - if ( - xml_space_attr not in elem.attrib - or elem.attrib[xml_space_attr] != "preserve" - ): - text_preview = ( - repr(text)[:50] + "..." - if len(repr(text)) > 50 - else repr(text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} whitespace preservation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All whitespace is properly preserved") - return True - - def validate_deletions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): - if t_elem.text: - text_preview = ( - repr(t_elem.text)[:50] + "..." - if len(repr(t_elem.text)) > 50 - else repr(t_elem.text) - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {t_elem.sourceline}: found within : {text_preview}" - ) - - for instr_elem in root.xpath( - ".//w:del//w:instrText", namespaces=namespaces - ): - text_preview = ( - repr(instr_elem.text or "")[:50] + "..." - if len(repr(instr_elem.text or "")) > 50 - else repr(instr_elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} deletion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:t elements found within w:del elements") - return True - - def count_paragraphs_in_unpacked(self): - count = 0 - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - except Exception as e: - print(f"Error counting paragraphs in unpacked document: {e}") - - return count - - def count_paragraphs_in_original(self): - original = self.original_file - if original is None: - return 0 - - count = 0 - - try: - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(original, "r") as zip_ref: - safe_extract(zip_ref, Path(temp_dir)) - - doc_xml_path = temp_dir + "/word/document.xml" - root = lxml.etree.parse(doc_xml_path).getroot() - - paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") - count = len(paragraphs) - - except Exception as e: - print(f"Error counting paragraphs in original document: {e}") - - return count - - def validate_insertions(self): - errors = [] - - for xml_file in self.xml_files: - if xml_file.name != "document.xml": - continue - - try: - root = lxml.etree.parse(str(xml_file)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - invalid_elements = root.xpath( - ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces - ) - - for elem in invalid_elements: - text_preview = ( - repr(elem.text or "")[:50] + "..." - if len(repr(elem.text or "")) > 50 - else repr(elem.text or "") - ) - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: within : {text_preview}" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} insertion validation violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - No w:delText elements within w:ins elements") - return True - - def compare_paragraph_counts(self): - new_count = self.count_paragraphs_in_unpacked() - if self.original_file is None: - print(f"\nParagraphs: {new_count}") - return - - original_count = self.count_paragraphs_in_original() - diff = new_count - original_count - diff_str = f"+{diff}" if diff > 0 else str(diff) - print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") - - def _parse_id_value(self, val: str, base: int = 16) -> int: - return int(val, base) - - def validate_id_constraints(self): - errors = [] - para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" - durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" - - for xml_file in self.xml_files: - try: - for elem in lxml.etree.parse(str(xml_file)).iter(): - if val := elem.get(para_id_attr): - try: - if self._parse_id_value(val, base=16) >= 0x80000000: - errors.append( - f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"paraId={val} is not valid hex" - ) - - if val := elem.get(durable_id_attr): - if xml_file.name == "numbering.xml": - try: - if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} must be decimal in numbering.xml" - ) - else: - try: - if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} >= 0x7FFFFFFF" - ) - except ValueError: - errors.append( - f" {xml_file.name}:{elem.sourceline}: " - f"durableId={val} is not valid hex" - ) - except lxml.etree.XMLSyntaxError: - continue - - if errors: - print(f"FAILED - {len(errors)} ID constraint violations:") - for e in errors: - print(e) - elif self.verbose: - print("PASSED - All paraId/durableId values within constraints") - return not errors - - def validate_comment_markers(self): - errors = [] - - document_xml = None - comments_xml = None - for xml_file in self.xml_files: - if xml_file.name == "document.xml" and "word" in str(xml_file): - document_xml = xml_file - elif xml_file.name == "comments.xml": - comments_xml = xml_file - - if not document_xml: - if self.verbose: - print("PASSED - No document.xml found (skipping comment validation)") - return True - - try: - doc_root = lxml.etree.parse(str(document_xml)).getroot() - namespaces = {"w": self.WORD_2006_NAMESPACE} - - range_starts = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeStart", namespaces=namespaces - ) - } - range_ends = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentRangeEnd", namespaces=namespaces - ) - } - references = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in doc_root.xpath( - ".//w:commentReference", namespaces=namespaces - ) - } - - orphaned_ends = range_ends - range_starts - for comment_id in sorted( - orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' - ) - - orphaned_starts = range_starts - range_ends - for comment_id in sorted( - orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - errors.append( - f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' - ) - - comment_ids = set() - if comments_xml and comments_xml.exists(): - comments_root = lxml.etree.parse(str(comments_xml)).getroot() - comment_ids = { - elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") - for elem in comments_root.xpath( - ".//w:comment", namespaces=namespaces - ) - } - - marker_ids = range_starts | range_ends | references - invalid_refs = marker_ids - comment_ids - for comment_id in sorted( - invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 - ): - if comment_id: - errors.append( - f' document.xml: marker id="{comment_id}" references non-existent comment' - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append(f" Error parsing XML: {e}") - - if errors: - print(f"FAILED - {len(errors)} comment marker violations:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All comment markers properly paired") - return True - - def repair(self) -> int: - repairs = super().repair() - repairs += self.repair_durableId() - return repairs - - def repair_durableId(self) -> int: - DURABLE_ID_ATTRS = ("w16cid:durableId", "w16cex:durableId") - repairs = 0 - renames: dict = {} - - for xml_file in self.xml_files: - try: - content = xml_file.read_text(encoding="utf-8") - dom = defusedxml.minidom.parseString(content) - is_numbering = xml_file.name == "numbering.xml" - base = 10 if is_numbering else 16 - pending = [] - seen_in_file = set() - modified = False - - for elem in dom.getElementsByTagName("*"): - for attr_name in DURABLE_ID_ATTRS: - if not elem.hasAttribute(attr_name): - continue - - durable_id = elem.getAttribute(attr_name) - try: - key = self._parse_id_value(durable_id, base=base) - needs_repair = key >= 0x7FFFFFFF - except ValueError: - key = durable_id - needs_repair = True - - if needs_repair: - if key in seen_in_file: - value = random.randint(1, 0x7FFFFFFE) - else: - seen_in_file.add(key) - if key not in renames: - renames[key] = random.randint(1, 0x7FFFFFFE) - value = renames[key] - new_id = str(value) if is_numbering else f"{value:08X}" - - elem.setAttribute(attr_name, new_id) - pending.append( - f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" - ) - modified = True - - if modified: - xml_file.write_bytes(dom.toxml(encoding="UTF-8")) - for message in pending: - print(message) - repairs += len(pending) - - except Exception: - pass - - return repairs - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py deleted file mode 100644 index 7b53d0d3e4..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/pptx.py +++ /dev/null @@ -1,441 +0,0 @@ -""" -Validator for PowerPoint presentation XML files against XSD schemas. -""" - -import re -from pathlib import Path - -from helpers import opc_target, rels_source_part, safe_extract - -from .base import BaseSchemaValidator - - -class PPTXSchemaValidator(BaseSchemaValidator): - - PRESENTATIONML_NAMESPACE = ( - "http://schemas.openxmlformats.org/presentationml/2006/main" - ) - - ELEMENT_RELATIONSHIP_TYPES = { - "sldid": "slide", - "sldmasterid": "slidemaster", - "notesmasterid": "notesmaster", - "sldlayoutid": "slidelayout", - "themeid": "theme", - "tablestyleid": "tablestyles", - } - - def validate(self): - if not self.validate_xml(): - return False - - all_valid = True - if not self.validate_namespaces(): - all_valid = False - - if not self.validate_unique_ids(): - all_valid = False - - if not self.validate_uuid_ids(): - all_valid = False - - if not self.validate_file_references(): - all_valid = False - - if not self.validate_slide_layout_ids(): - all_valid = False - - if not self.validate_content_types(): - all_valid = False - - if not self.validate_against_xsd(): - all_valid = False - - if not self.validate_notes_slide_references(): - all_valid = False - - if not self.validate_all_relationship_ids(): - all_valid = False - - if not self.validate_no_duplicate_slide_layouts(): - all_valid = False - - if not self.validate_master_theme_uniqueness(): - all_valid = False - - if not self.validate_charts(): - all_valid = False - - if not self.validate_slides(): - all_valid = False - - return all_valid - - def _package_map(self) -> dict: - wanted = [] - wanted += list(self.unpacked_dir.glob("[[]Content_Types[]].xml")) - wanted += list(self.unpacked_dir.glob("ppt/presentation.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/*.xml")) - wanted += list(self.unpacked_dir.glob("ppt/theme/_rels/*.rels")) - wanted += list(self.unpacked_dir.glob("ppt/charts/chart*.xml")) - for group in ("slideMasters", "notesMasters", "handoutMasters"): - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/*.xml")) - wanted += list(self.unpacked_dir.glob(f"ppt/{group}/_rels/*.rels")) - return { - p.relative_to(self.unpacked_dir).as_posix(): p.read_bytes() - for p in wanted - if p.is_file() - } - - def validate_master_theme_uniqueness(self): - from helpers.pptx_theme import _NOTES_MASTERS, live_shared_master_themes - - shared = live_shared_master_themes(self._package_map()) - if shared: - print(f"FAILED - Found {len(shared)} master(s) sharing a theme part:") - for message in shared: - print(f" {message}") - if any(m.startswith(_NOTES_MASTERS) for m in shared): - print(" Fix: in ppt/presentation.xml, move back to " - "directly after . PowerPoint reads that happily.") - else: - print(" Fix: give each master its own theme part.") - return False - - if self.verbose: - print("PASSED - No master shares a theme part in a way PowerPoint refuses") - return True - - def validate_charts(self): - from helpers.pptx_chart import find_chart_problems - - problems = find_chart_problems(self._package_map()) - if problems: - print(f"FAILED - Found {len(problems)} chart problem(s) PowerPoint rejects:") - for message in problems: - print(f" {message}") - return False - - if self.verbose: - print("PASSED - Charts satisfy the constraints PowerPoint enforces") - return True - - def _original_slide_defects(self, schema) -> set[str]: - import tempfile - import zipfile - - from helpers.pptx_slide import SLIDE_PART_RE, fatal_slide_errors - - if self.original_file is None: - return set() - - found: set[str] = set() - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - try: - with zipfile.ZipFile(self.original_file, "r") as zf: - safe_extract(zf, temp_path) - except (zipfile.BadZipFile, ValueError, OSError): - return set() - - for part in sorted(temp_path.rglob("*.xml")): - relative = part.relative_to(temp_path).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - part.resolve(), temp_path.resolve(), schema_path=schema - ) - if ok is None or ok or not errors: - continue - found |= set(fatal_slide_errors(set(errors))) - return found - - def validate_slides(self): - from helpers.pptx_slide import ( - SLIDE_PART_RE, - fatal_slide_errors, - is_schema_verdict, - ) - - schema = self.schemas_dir / self.SCHEMA_MAPPINGS["ppt"] - inherited = self._original_slide_defects(schema) - problems: list[str] = [] - broken: list[str] = [] - - for xml_file in self.xml_files: - relative = xml_file.relative_to(self.unpacked_dir).as_posix() - if not SLIDE_PART_RE.fullmatch(relative): - continue - ok, errors = self._validate_single_file_xsd( - xml_file.resolve(), self.unpacked_dir.resolve(), schema_path=schema - ) - if ok is None or not errors: - continue - - unreadable = [f"{relative}: {e}" for e in errors if not is_schema_verdict(e)] - if unreadable: - broken.extend(unreadable) - continue - if ok: - continue - - for message in fatal_slide_errors(set(errors)): - if message in inherited: - continue - problems.append(f"{relative}: {message}") - - if broken: - print(f"FAILED - Could not check {len(broken)} slide part(s):") - for message in sorted(broken): - print(f" {message[:240]}") - - if problems: - print(f"FAILED - Found {len(problems)} slide problem(s) PowerPoint rejects:") - for message in sorted(problems): - print(f" {message[:240]}") - - if broken or problems: - return False - - if self.verbose: - print("PASSED - Slide XML has none of the defects PowerPoint refuses") - return True - - def _get_schema_path(self, xml_file): - if xml_file.parent.name == "charts" and xml_file.name.startswith("chart"): - return None - return super()._get_schema_path(xml_file) - - def _preprocess_for_schema(self, xml_doc, relative_path): - if relative_path.as_posix() != "ppt/presentation.xml": - return xml_doc - - root = xml_doc.getroot() - ns = f"{{{self.PRESENTATIONML_NAMESPACE}}}" - notes = root.find(f"{ns}notesMasterIdLst") - slides = root.find(f"{ns}sldIdLst") - if notes is None or slides is None: - return xml_doc - - children = list(root) - if children.index(notes) < children.index(slides): - return xml_doc - - root.remove(notes) - root.insert(list(root).index(slides), notes) - return xml_doc - - def validate_uuid_ids(self): - import lxml.etree - - errors = [] - uuid_pattern = re.compile( - r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" - ) - - for xml_file in self.xml_files: - try: - root = lxml.etree.parse(str(xml_file)).getroot() - - for elem in root.iter(): - for attr, value in elem.attrib.items(): - attr_name = attr.split("}")[-1].lower() - if attr_name == "id" or attr_name.endswith("id"): - if self._looks_like_uuid(value): - if not uuid_pattern.match(value): - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: " - f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} UUID ID validation errors:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All UUID-like IDs contain valid hex values") - return True - - def _looks_like_uuid(self, value): - clean_value = value.strip("{}()").replace("-", "") - return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) - - def validate_slide_layout_ids(self): - import lxml.etree - - errors = [] - - slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) - - if not slide_masters: - if self.verbose: - print("PASSED - No slide masters found") - return True - - for slide_master in slide_masters: - try: - root = lxml.etree.parse(str(slide_master)).getroot() - - rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" - - if not rels_file.exists(): - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" - ) - continue - - rels_root = lxml.etree.parse(str(rels_file)).getroot() - - valid_layout_rids = set() - for rel in rels_root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "slideLayout" in rel_type: - valid_layout_rids.add(rel.get("Id")) - - for sld_layout_id in root.findall( - f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" - ): - r_id = sld_layout_id.get( - f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" - ) - layout_id = sld_layout_id.get("id") - - if r_id and r_id not in valid_layout_rids: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: " - f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " - f"references r:id='{r_id}' which is not found in slide layout relationships" - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") - for error in errors: - print(error) - print( - "Remove invalid references or add missing slide layouts to the relationships file." - ) - return False - else: - if self.verbose: - print("PASSED - All slide layout IDs reference valid slide layouts") - return True - - def validate_no_duplicate_slide_layouts(self): - import lxml.etree - - errors = [] - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - layout_rels = [ - rel - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ) - if "slideLayout" in rel.get("Type", "") - ] - - if len(layout_rels) > 1: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" - ) - - except Exception as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - if errors: - print("FAILED - Found slides with duplicate slideLayout references:") - for error in errors: - print(error) - return False - else: - if self.verbose: - print("PASSED - All slides have exactly one slideLayout reference") - return True - - def validate_notes_slide_references(self): - import lxml.etree - - errors = [] - notes_slide_references = {} - - slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) - - if not slide_rels_files: - if self.verbose: - print("PASSED - No slide relationship files found") - return True - - for rels_file in slide_rels_files: - try: - root = lxml.etree.parse(str(rels_file)).getroot() - - for rel in root.findall( - f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" - ): - rel_type = rel.get("Type", "") - if "notesSlide" in rel_type: - part = opc_target( - rel.get("Target", ""), - rels_source_part(rels_file, self.unpacked_dir), - rel.get("TargetMode", ""), - ) - if part: - slide_name = rels_file.stem.replace( - ".xml", "" - ) - - notes_slide_references.setdefault(part, []).append( - (slide_name, rels_file) - ) - - except (lxml.etree.XMLSyntaxError, Exception) as e: - errors.append( - f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" - ) - - for target, references in notes_slide_references.items(): - if len(references) > 1: - slide_names = [ref[0] for ref in references] - errors.append( - f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" - ) - for slide_name, rels_file in references: - errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") - - if errors: - print( - f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" - ) - for error in errors: - print(error) - print("Each slide may optionally have its own slide file.") - return False - else: - if self.verbose: - print("PASSED - All notes slide references are unique") - return True - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py deleted file mode 100644 index 18d0c68be9..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/office/validators/redlining.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Validator for tracked changes in Word documents. - -Detects untracked edits in word/document.xml: text that differs from the -original without a / wrapper recording it. The tracked changes -that are new relative to the original are undone, and the result is compared -against the original; whatever text still differs was edited without being -tracked. - -Only the document body is compared. Headers, footers, footnotes and endnotes -are separate parts and are not checked. -""" - -import subprocess -import tempfile -import zipfile -from pathlib import Path - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from helpers import rendered_text, safe_extract - - -class RedliningValidator: - - def __init__(self, unpacked_dir, original_docx, verbose=False): - self.unpacked_dir = Path(unpacked_dir) - self.original_docx = Path(original_docx) - self.verbose = verbose - self.namespaces = { - "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" - } - - def repair(self) -> int: - return 0 - - def validate(self): - modified_file = self.unpacked_dir / "word" / "document.xml" - if not modified_file.exists(): - print(f"FAILED - Modified document.xml not found at {modified_file}") - return False - - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - try: - with zipfile.ZipFile(self.original_docx, "r") as zip_ref: - safe_extract(zip_ref, temp_path) - except Exception as e: - print(f"FAILED - Error unpacking original docx: {e}") - return False - - original_file = temp_path / "word" / "document.xml" - if not original_file.exists(): - print( - f"FAILED - Original document.xml not found in {self.original_docx}" - ) - return False - - try: - modified_tree = ET.parse(modified_file) - modified_root = modified_tree.getroot() - original_tree = ET.parse(original_file) - original_root = original_tree.getroot() - except (ET.ParseError, DefusedXmlException) as e: - print(f"FAILED - Error parsing XML files: {e}") - return False - - new_changes = self._new_tracked_changes(original_root, modified_root) - self._remove_tracked_changes(modified_root, new_changes) - - modified_text = self._extract_text_content(modified_root) - original_text = self._extract_text_content(original_root) - - if modified_text != original_text: - error_message = self._generate_detailed_diff( - original_text, modified_text - ) - print(error_message) - return False - - if self.verbose: - print( - f"PASSED - All {len(new_changes)} change(s) against the original " - "are properly tracked" - ) - return True - - def _tracked_change_elements(self, root): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - return [elem for elem in root.iter() if elem.tag in (ins_tag, del_tag)] - - def _rendered_text(self, elem): - preserve = elem.get("{http://www.w3.org/XML/1998/namespace}space") == "preserve" - return rendered_text(elem.text or "", preserve) - - def _text_elements(self, elem): - w = self.namespaces["w"] - return [ - node - for node in elem.iter() - if node.tag in (f"{{{w}}}t", f"{{{w}}}delText") - ] - - def _tracked_change_key(self, elem): - w = self.namespaces["w"] - text = "".join(self._rendered_text(node) for node in self._text_elements(elem)) - return (elem.tag, elem.get(f"{{{w}}}author"), elem.get(f"{{{w}}}date"), text) - - def _new_tracked_changes(self, original_root, modified_root): - original = self._tracked_change_elements(original_root) - modified = self._tracked_change_elements(modified_root) - - pool = {} - for elem in original: - pool.setdefault(self._tracked_change_key(elem), []).append(elem) - - matched, leftover = set(), [] - for elem in modified: - bucket = pool.get(self._tracked_change_key(elem)) - if bucket: - matched.add(bucket.pop()) - else: - leftover.append(elem) - - def group(elem): - return self._tracked_change_key(elem)[:3] - - def text_of(elems): - return "".join(self._tracked_change_key(e)[3] for e in elems) - - unmatched_original = {} - for elem in original: - if elem not in matched: - unmatched_original.setdefault(group(elem), []).append(elem) - - by_group = {} - for elem in leftover: - by_group.setdefault(group(elem), []).append(elem) - - new = set() - for key, elems in by_group.items(): - rebuilt = text_of(elems) - if rebuilt and rebuilt == text_of(unmatched_original.get(key, [])): - continue - new.update(elems) - return new - - def _generate_detailed_diff(self, original_text, modified_text): - error_parts = [ - "FAILED - Document text doesn't match after removing the tracked changes", - "", - "Likely causes:", - " 1. Modified text inside another author's or tags", - " 2. Made edits without proper tracked changes", - " 3. Didn't nest inside when deleting another's insertion", - " 4. Rewrote another author's / and changed its text on", - " the way. A tracked change from the original is recognised by its", - " author, date and text; anything that doesn't reproduce one exactly", - " reads as new, and the text it carried is reported missing.", - "", - "For pre-redlined documents, use correct patterns:", - " - To reject another's INSERTION: Nest inside their ", - " - To reject PART of one: nest around only the runs you reject.", - " Their may be split around it, so long as the pieces keep", - " their author and date and still spell out the same text.", - " - To restore another's DELETION: Add new AFTER their ", - "", - ] - - git_diff = self._get_git_word_diff(original_text, modified_text) - if git_diff: - error_parts.extend(["Differences:", "============", git_diff]) - else: - error_parts.append("Unable to generate word diff (git not available)") - - return "\n".join(error_parts) - - def _get_git_word_diff(self, original_text, modified_text): - try: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - original_file = temp_path / "original.txt" - modified_file = temp_path / "modified.txt" - - original_file.write_text(original_text, encoding="utf-8") - modified_file.write_text(modified_text, encoding="utf-8") - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "--word-diff-regex=.", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - - if content_lines: - return "\n".join(content_lines) - - result = subprocess.run( - [ - "git", - "diff", - "--word-diff=plain", - "-U0", - "--no-index", - str(original_file), - str(modified_file), - ], - capture_output=True, - text=True, - ) - - if result.stdout.strip(): - lines = result.stdout.split("\n") - content_lines = [] - in_content = False - for line in lines: - if line.startswith("@@"): - in_content = True - continue - if in_content and line.strip(): - content_lines.append(line) - return "\n".join(content_lines) - - except (subprocess.CalledProcessError, FileNotFoundError, Exception): - pass - - return None - - def _remove_tracked_changes(self, root, targets): - ins_tag = f"{{{self.namespaces['w']}}}ins" - del_tag = f"{{{self.namespaces['w']}}}del" - - for parent in root.iter(): - to_remove = [] - for child in parent: - if child.tag == ins_tag and child in targets: - to_remove.append(child) - for elem in to_remove: - parent.remove(elem) - - deltext_tag = f"{{{self.namespaces['w']}}}delText" - t_tag = f"{{{self.namespaces['w']}}}t" - - for parent in root.iter(): - to_process = [] - for child in parent: - if child.tag == del_tag and child in targets: - to_process.append((child, list(parent).index(child))) - - for del_elem, del_index in reversed(to_process): - for elem in del_elem.iter(): - if elem.tag == deltext_tag: - elem.tag = t_tag - - for child in reversed(list(del_elem)): - parent.insert(del_index, child) - parent.remove(del_elem) - - def _extract_text_content(self, root): - p_tag = f"{{{self.namespaces['w']}}}p" - t_tag = f"{{{self.namespaces['w']}}}t" - - paragraphs = [] - for p_elem in root.findall(f".//{p_tag}"): - text_parts = [] - for t_elem in p_elem.findall(f".//{t_tag}"): - text_parts.append(self._rendered_text(t_elem)) - paragraph_text = "".join(text_parts) - if paragraph_text: - paragraphs.append(paragraph_text) - - return "\n".join(paragraphs) - - -if __name__ == "__main__": - raise RuntimeError("This module should not be run directly.") diff --git a/src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py b/src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py deleted file mode 100755 index ba6d0c3ad1..0000000000 --- a/src/crates/assembly/core/builtin_skills/xlsx/scripts/recalc.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Excel Formula Recalculation Script -Recalculates all formulas in an Excel file using LibreOffice -""" - -import contextlib -import json -import os -import platform -import re -import shutil -import subprocess -import sys -import tempfile -import time -import zipfile -from pathlib import Path - -from office.soffice import get_soffice_env, run_soffice - -from openpyxl import load_workbook - -MACRO_FILENAME = "Module1.xba" -SOFFICE_MISSING = "soffice not found on PATH; LibreOffice is required to recalculate" - -MAX_LOCATIONS = 100 - -EXTERNAL_REF_RE = re.compile(r"""(? - - - Sub RecalculateAndSave() - ThisComponent.calculateAll() - ThisComponent.store() - ThisComponent.close(True) - End Sub -""" - - -def has_gtimeout(): - try: - subprocess.run( - ["gtimeout", "--version"], capture_output=True, timeout=1, check=False - ) - return True - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - - -def _stamp(path): - st = os.stat(path) - return st.st_mtime_ns, st.st_size - - -def setup_libreoffice_macro(profile_dir: Path, timeout=30): - url = profile_dir.as_uri() - try: - run_soffice( - ["--headless", "--terminate_after_init", f"-env:UserInstallation={url}"], - capture_output=True, - timeout=timeout, - ) - except FileNotFoundError: - return None, SOFFICE_MISSING - except subprocess.TimeoutExpired: - return None, "LibreOffice timed out creating its profile; formulas were NOT recalculated" - - macro_dir = profile_dir / "user" / "basic" / "Standard" - if not macro_dir.exists(): - return None, "LibreOffice did not create a usable profile; formulas were NOT recalculated" - - try: - (macro_dir / MACRO_FILENAME).write_text(RECALCULATE_MACRO) - except OSError as e: - return None, f"Could not install the recalculation macro: {e}" - - return url, None - - -def external_links_at_risk(filename): - try: - with zipfile.ZipFile(filename) as archive: - names = archive.namelist() - except (zipfile.BadZipFile, OSError): - return [] - if not any(n.startswith("xl/externalLinks/") for n in names): - return [] - - with contextlib.ExitStack() as stack: - formulas = load_workbook(filename, data_only=False) - stack.callback(formulas.close) - values = load_workbook(filename, data_only=True) - stack.callback(values.close) - - external_names = [ - name - for name, dn in formulas.defined_names.items() - if isinstance(getattr(dn, "value", None), str) and EXTERNAL_REF_RE.search(dn.value) - ] - name_re = ( - re.compile(r"\b(" + "|".join(re.escape(n) for n in external_names) + r")\b") - if external_names - else None - ) - - at_risk = [] - for sheet in formulas.sheetnames: - ws = formulas[sheet] - if not hasattr(ws, "iter_rows"): - continue - cached = values[sheet] - for row in ws.iter_rows(): - for cell in row: - v = cell.value - if not (isinstance(v, str) and v.startswith("=")): - continue - reaches_out = EXTERNAL_REF_RE.search(v) or (name_re and name_re.search(v)) - if reaches_out and cached[cell.coordinate].value is None: - at_risk.append(f"{sheet}!{cell.coordinate}") - return at_risk - - -def recalc(filename, timeout=30, force=False): - if not Path(filename).exists(): - return {"error": f"File {filename} does not exist"} - - abs_path = str(Path(filename).absolute()) - - if not os.access(abs_path, os.W_OK): - return {"error": f"{filename} is not writable; recalculation rewrites the file in place"} - - try: - get_soffice_env() - except Exception as e: - return {"error": f"Could not prepare the LibreOffice environment: {e}"} - - if not force: - try: - at_risk = external_links_at_risk(filename) - except Exception as e: - return {"error": f"Could not inspect {filename} for external links: {e}"} - if at_risk: - shown = at_risk[:MAX_LOCATIONS] - return { - "error": ( - "Refusing to recalculate: this workbook links to another workbook, and " - f"{len(at_risk)} linked cell(s) have lost their cached value (openpyxl strips " - "these on save). Recalculating would resolve them to #NAME? and delete the " - "external links for good. Copy those cells' values from the original file " - "before saving, or pass --force to accept the loss. Charts and conditional " - "formats can hold external references too, so this list may not be exhaustive." - ), - "external_link_cells": shown, - "external_link_cells_truncated": max(0, len(at_risk) - len(shown)), - } - - with tempfile.TemporaryDirectory( - prefix="recalc-lo-profile-", ignore_cleanup_errors=True - ) as profile_dir: - return _recalc_with_profile(filename, abs_path, timeout, Path(profile_dir)) - - -def _recalc_with_profile(filename, abs_path, timeout, profile_dir: Path): - started = time.monotonic() - profile_url, err = setup_libreoffice_macro(profile_dir, timeout=timeout) - if err: - return {"error": err} - - timeout = max(5, int(timeout - (time.monotonic() - started))) - - before = _stamp(abs_path) - - cmd = [ - "soffice", - "--headless", - "--norestore", - f"-env:UserInstallation={profile_url}", - "vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application", - abs_path, - ] - - if platform.system() == "Linux" and shutil.which("timeout"): - cmd = ["timeout", str(timeout)] + cmd - elif platform.system() == "Darwin" and has_gtimeout(): - cmd = ["gtimeout", str(timeout)] + cmd - - timed_out = f"LibreOffice timed out after {timeout}s; formulas were NOT recalculated. Re-run with a longer timeout." - - try: - result = subprocess.run( - cmd, capture_output=True, text=True, env=get_soffice_env(), timeout=timeout + 15 - ) - except subprocess.TimeoutExpired: - return {"error": timed_out} - except FileNotFoundError: - return {"error": SOFFICE_MISSING} - - if result.returncode == 124: - return {"error": timed_out} - - if result.returncode != 0: - detail = (result.stderr or "").strip() or f"soffice exited {result.returncode}" - return {"error": f"LibreOffice failed to recalculate: {detail}"} - - if _stamp(abs_path) == before: - return { - "error": ( - "LibreOffice exited cleanly but never rewrote the file, so nothing was " - "recalculated. Check that no other LibreOffice instance is running, then retry." - ) - } - - try: - wb = load_workbook(filename, data_only=True) - - excel_errors = [ - "#VALUE!", - "#DIV/0!", - "#REF!", - "#NAME?", - "#NULL!", - "#NUM!", - "#N/A", - ] - error_details = {err: [] for err in excel_errors} - total_errors = 0 - - for sheet_name in wb.sheetnames: - ws = wb[sheet_name] - if not hasattr(ws, "iter_rows"): - continue - for row in ws.iter_rows(): - for cell in row: - if cell.value is not None and isinstance(cell.value, str): - for err in excel_errors: - if err in cell.value: - location = f"{sheet_name}!{cell.coordinate}" - error_details[err].append(location) - total_errors += 1 - break - - result = { - "status": "success" if total_errors == 0 else "errors_found", - "total_errors": total_errors, - "error_summary": {}, - } - - for err_type, locations in error_details.items(): - if locations: - entry = {"count": len(locations), "locations": locations[:MAX_LOCATIONS]} - if len(locations) > MAX_LOCATIONS: - entry["locations_truncated"] = len(locations) - MAX_LOCATIONS - result["error_summary"][err_type] = entry - - wb.close() - - wb_formulas = load_workbook(filename, data_only=False) - formula_count = 0 - for sheet_name in wb_formulas.sheetnames: - ws = wb_formulas[sheet_name] - if not hasattr(ws, "iter_rows"): - continue - for row in ws.iter_rows(): - for cell in row: - if ( - cell.value - and isinstance(cell.value, str) - and cell.value.startswith("=") - ): - formula_count += 1 - wb_formulas.close() - - result["total_formulas"] = formula_count - - return result - - except Exception as e: - return {"error": str(e)} - - -def main(): - args = [a for a in sys.argv[1:] if a != "--force"] - force = "--force" in sys.argv[1:] - - if not args: - print("Usage: python recalc.py [timeout_seconds] [--force]") - print("\nRecalculates all formulas in an Excel file using LibreOffice") - print("\nReturns JSON with error details:") - print(" - status: 'success' or 'errors_found'") - print(" - total_errors: Total number of Excel errors found") - print(" - total_formulas: Number of formulas in the file") - print(" - error_summary: Breakdown by error type with locations") - print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A") - print("\nOn any failure the JSON has an 'error' key and no 'status'.") - print("--force recalculates even when it would destroy external links.") - sys.exit(1) - - filename = args[0] - timeout = int(args[1]) if len(args) > 1 else 30 - - result = recalc(filename, timeout, force=force) - print(json.dumps(result, indent=2)) - sys.exit(1 if "error" in result else 0) - - -if __name__ == "__main__": - main() diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 05bc5acfec..71ee6c1bdc 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -34,9 +34,8 @@ How to use skills: - Pass user-provided invocation text relevant to the skill through `arguments`; never copy an `argument-hint` into arguments - The skill's prompt will expand and provide detailed instructions on how to complete the task - Examples: - - `command: "pdf"` - invoke the pdf skill + - `command: "writing-skills"` - invoke the writing-skills skill - `command: "review", arguments: "src/main.rs carefully"` - invoke a skill with arguments - - `command: "xlsx"` - invoke the xlsx skill - `command: "user::bitfun-system::ppt-design"` - invoke a specific built-in skill by stable key Important: @@ -137,7 +136,7 @@ impl Tool for SkillTool { "properties": { "command": { "type": "string", - "description": "The skill name or stable key. E.g., \"pdf\" or \"user::bitfun-system::ppt-design\"" + "description": "The skill name or stable key. E.g., \"writing-skills\" or \"user::bitfun-system::ppt-design\"" }, "arguments": { "type": "string", diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs index dbc9759b85..6b5bf38af5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs @@ -25,6 +25,11 @@ const BUILTIN_SKILLS_MANIFEST_FILE_NAME: &str = ".manifest.json"; const BUILTIN_SKILLS_INSTALL_LOCK_FILE_NAME: &str = ".system.install.lock"; const BUILTIN_SKILLS_STAGING_PREFIX: &str = ".system.tmp"; const LEGACY_BUILTIN_SKILL_DIR_NAMES: &[&str] = &[ + // Redistribution-restricted upstream skills removed in 2026-08. + "docx", + "pdf", + "pptx", + "xlsx", // Historical bundled "Superpowers" skills removed in 2026-04. "brainstorming", "dispatching-parallel-agents", @@ -367,7 +372,7 @@ async fn desired_file_content( #[cfg(test)] mod tests { - use super::{collect_files, BUILTIN_SKILLS_DIR}; + use super::{BUILTIN_SKILLS_DIR, LEGACY_BUILTIN_SKILL_DIR_NAMES}; fn embedded_skill_text(path: &str) -> &'static str { BUILTIN_SKILLS_DIR @@ -552,79 +557,15 @@ mod tests { } #[test] - fn office_helpers_use_validated_archive_extraction() { - for skill in ["docx", "pptx", "xlsx"] { - let helper_path = format!("{skill}/scripts/office/helpers/__init__.py"); - let helper = embedded_skill_text(&helper_path); + fn redistribution_restricted_skills_are_not_embedded() { + for skill in ["docx", "pdf", "pptx", "xlsx"] { assert!( - helper.contains("def safe_extract("), - "{helper_path} lacks safe_extract" + BUILTIN_SKILLS_DIR.get_dir(skill).is_none(), + "redistribution-restricted skill {skill} must not be embedded" ); assert!( - helper.contains("stat.S_ISLNK"), - "{helper_path} lacks symlink rejection" - ); - assert!( - helper.contains("MAX_ARCHIVE_TOTAL_SIZE") - && helper.contains("MAX_ARCHIVE_COMPRESSION_RATIO") - && helper.contains("duplicate archive entry"), - "{helper_path} lacks bounded, collision-safe extraction" - ); - - let dir = BUILTIN_SKILLS_DIR - .get_dir(skill) - .unwrap_or_else(|| panic!("Missing embedded Office skill {skill}")); - let mut files = Vec::new(); - collect_files(dir, &mut files); - for file in files { - let text = file.contents_utf8().unwrap_or(""); - assert!( - !text.contains(".extractall("), - "{} still uses unrestricted ZipFile.extractall", - file.path().display() - ); - } - - assert!(dir - .get_file(format!("{skill}/scripts/office/pack.py")) - .is_none()); - assert!(dir - .get_file(format!("{skill}/scripts/office/unpack.py")) - .is_none()); - - if matches!(skill, "docx" | "pptx") { - let skill_text = embedded_skill_text(&format!("{skill}/SKILL.md")); - assert!( - skill_text.contains("safe_extract") && skill_text.contains("rezip"), - "{skill}/SKILL.md must use the cross-platform safe archive helpers" - ); - assert!( - !skill_text.contains("unzip -q") && !skill_text.contains("zip -Xr"), - "{skill}/SKILL.md still recommends unsafe or non-portable archive commands" - ); - } - } - - let comment = embedded_skill_text("docx/scripts/comment.py"); - assert!(comment.contains("author: str = \"BitFun\"")); - assert!(comment.contains("initials: str = \"B\"")); - assert!(comment.contains("default=\"BitFun\"")); - assert!(comment.contains("default=\"B\"")); - - let docx_skill = embedded_skill_text("docx/SKILL.md"); - assert!(docx_skill.contains( - "Use \"BitFun\" as the author for tracked changes and comments unless the user explicitly requests a different name." - )); - - let xlsx_skill = embedded_skill_text("xlsx/SKILL.md"); - assert!(xlsx_skill.contains("years as text (`\"2026\"`, never `2,026`)")); - - let docx_helper = embedded_skill_text("docx/scripts/office/helpers/__init__.py"); - for skill in ["pptx", "xlsx"] { - assert_eq!( - docx_helper, - embedded_skill_text(&format!("{skill}/scripts/office/helpers/__init__.py")), - "Office safe extraction helpers drifted between bundled skills" + LEGACY_BUILTIN_SKILL_DIR_NAMES.contains(&skill), + "removed skill {skill} must be cleaned from legacy user skill roots" ); } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs index 009c0a4263..ff43b4b58f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs @@ -11,11 +11,10 @@ mod tests { #[test] fn builtin_skill_groups_match_expected_sets() { - assert_eq!(builtin_skill_group_key("docx"), Some("office")); - assert_eq!(builtin_skill_group_key("pdf"), Some("office")); assert_eq!(builtin_skill_group_key("ppt-design"), Some("office")); - assert_eq!(builtin_skill_group_key("pptx"), Some("office")); - assert_eq!(builtin_skill_group_key("xlsx"), Some("office")); + for removed in ["docx", "pdf", "pptx", "xlsx"] { + assert_eq!(builtin_skill_group_key(removed), None); + } assert_eq!(builtin_skill_group_key("create-bitfun-skin"), Some("meta")); assert_eq!(builtin_skill_group_key("find-skills"), Some("meta")); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs index 417fa32ff2..063ac0480f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/policy.rs @@ -10,10 +10,6 @@ mod tests { #[test] fn builtin_defaults_follow_mode_policies() { - assert_eq!( - resolve_builtin_default_enabled("pdf", "agentic"), - Some(false) - ); assert_eq!( resolve_builtin_default_enabled("ppt-design", "agentic"), Some(false) @@ -26,7 +22,6 @@ mod tests { resolve_builtin_default_enabled("agent-browser", "agentic"), Some(false) ); - assert_eq!(resolve_builtin_default_enabled("pdf", "Cowork"), Some(true)); assert_eq!( resolve_builtin_default_enabled("agent-browser", "Cowork"), Some(false) @@ -35,7 +30,10 @@ mod tests { resolve_builtin_default_enabled("gstack-review", "Team"), Some(true) ); - assert_eq!(resolve_builtin_default_enabled("pdf", "Team"), Some(false)); + assert_eq!( + resolve_builtin_default_enabled("ppt-design", "Team"), + Some(false) + ); assert_eq!( resolve_builtin_default_enabled("miniapp-dev", "Team"), Some(false) @@ -45,23 +43,29 @@ mod tests { Some(true) ); assert_eq!( - resolve_builtin_default_enabled("pdf", "DeepResearch"), + resolve_builtin_default_enabled("ppt-design", "DeepResearch"), Some(false) ); assert_eq!( resolve_builtin_default_enabled("agent-browser", "Claw"), Some(false) ); - assert_eq!(resolve_builtin_default_enabled("pdf", "Claw"), Some(false)); + assert_eq!( + resolve_builtin_default_enabled("ppt-design", "Claw"), + Some(false) + ); assert_eq!( resolve_builtin_default_enabled("agent-browser", "coding_shared"), Some(false) ); assert_eq!( - resolve_builtin_default_enabled("pdf", "coding_shared"), + resolve_builtin_default_enabled("ppt-design", "coding_shared"), + Some(false) + ); + assert_eq!( + resolve_builtin_default_enabled("ppt-design", "Other"), Some(false) ); - assert_eq!(resolve_builtin_default_enabled("pdf", "Other"), Some(false)); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs index 8af83f3ef7..2fbe7b80c3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs @@ -60,14 +60,20 @@ mod tests { #[test] fn builtin_default_state_follows_policy() { - let pdf = builtin_skill("pdf"); + let presentation = builtin_skill("ppt-design"); let browser = builtin_skill("agent-browser"); - assert!(!resolve_skill_default_enabled_for_mode(&pdf, "agentic")); + assert!(!resolve_skill_default_enabled_for_mode( + &presentation, + "agentic" + )); // agent-browser is opt-in everywhere: ControlHub's browser domain is // the default browser-automation path. assert!(!resolve_skill_default_enabled_for_mode(&browser, "agentic")); - assert!(resolve_skill_default_enabled_for_mode(&pdf, "Cowork")); + assert!(resolve_skill_default_enabled_for_mode( + &presentation, + "Cowork" + )); assert!(!resolve_skill_default_enabled_for_mode(&browser, "Cowork")); } @@ -88,21 +94,21 @@ mod tests { #[test] fn overrides_apply_on_top_of_defaults() { - let pdf = builtin_skill("pdf"); + let presentation = builtin_skill("ppt-design"); let mut overrides = UserModeSkillOverrides::default(); let disabled_project = HashSet::new(); let disabled_state = - resolve_skill_state_for_mode(&pdf, "agentic", &overrides, &disabled_project); + resolve_skill_state_for_mode(&presentation, "agentic", &overrides, &disabled_project); assert!(!disabled_state.effective_enabled); assert_eq!( disabled_state.reason, ModeSkillStateReason::BuiltinPolicyDisabled ); - overrides.enabled_skills.push(pdf.key.clone()); + overrides.enabled_skills.push(presentation.key.clone()); let enabled_state = - resolve_skill_state_for_mode(&pdf, "agentic", &overrides, &disabled_project); + resolve_skill_state_for_mode(&presentation, "agentic", &overrides, &disabled_project); assert!(enabled_state.effective_enabled); assert_eq!( enabled_state.reason, diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index b07f45bf90..0a85c55927 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -590,18 +590,24 @@ mod tests { fn normalize_skill_override_lists_removes_duplicates_and_conflicts() { let (disabled, enabled) = normalize_skill_override_lists( vec![ - "user::bitfun-system::pdf".to_string(), - "user::bitfun-system::pdf".to_string(), + "user::bitfun-system::ppt-design".to_string(), + "user::bitfun-system::ppt-design".to_string(), ], vec![ - "user::bitfun-system::pdf".to_string(), - "user::bitfun-system::docx".to_string(), - "user::bitfun-system::docx".to_string(), + "user::bitfun-system::ppt-design".to_string(), + "user::bitfun-system::agent-browser".to_string(), + "user::bitfun-system::agent-browser".to_string(), ], ); - assert_eq!(disabled, vec!["user::bitfun-system::pdf".to_string()]); - assert_eq!(enabled, vec!["user::bitfun-system::docx".to_string()]); + assert_eq!( + disabled, + vec!["user::bitfun-system::ppt-design".to_string()] + ); + assert_eq!( + enabled, + vec!["user::bitfun-system::agent-browser".to_string()] + ); } #[test] @@ -612,7 +618,7 @@ mod tests { added_tools: Vec::new(), removed_tools: Vec::new(), disabled_user_skills: Vec::new(), - enabled_user_skills: vec!["user::bitfun-system::pdf".to_string()], + enabled_user_skills: vec!["user::bitfun-system::ppt-design".to_string()], subagent_overrides: Default::default(), tool_permission_rules: Vec::new(), default_tools: &[], @@ -623,7 +629,7 @@ mod tests { assert_eq!(stored.profile_id, "coding_shared"); assert_eq!( stored.enabled_user_skills, - vec!["user::bitfun-system::pdf".to_string()] + vec!["user::bitfun-system::ppt-design".to_string()] ); assert!(stored.disabled_user_skills.is_empty()); } diff --git a/src/crates/assembly/core/tests/office_archive_safety.py b/src/crates/assembly/core/tests/office_archive_safety.py deleted file mode 100644 index e73ea02ee9..0000000000 --- a/src/crates/assembly/core/tests/office_archive_safety.py +++ /dev/null @@ -1,117 +0,0 @@ -import importlib.util -import io -import stat -import sys -import tempfile -import unittest -import warnings -import zipfile -from pathlib import Path - - -sys.dont_write_bytecode = True - - -CORE_ROOT = Path(__file__).resolve().parents[1] -SKILLS_ROOT = CORE_ROOT / "builtin_skills" - - -def load_helpers(skill: str): - path = SKILLS_ROOT / skill / "scripts" / "office" / "helpers" / "__init__.py" - spec = importlib.util.spec_from_file_location(f"{skill}_office_helpers", path) - if spec is None or spec.loader is None: - raise RuntimeError(f"cannot load Office helpers from {path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def archive_bytes(entries, compression=zipfile.ZIP_STORED): - data = io.BytesIO() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - with zipfile.ZipFile(data, "w", compression=compression) as archive: - for name, content in entries: - archive.writestr(name, content) - data.seek(0) - return data - - -class OfficeArchiveSafetyTests(unittest.TestCase): - def setUp(self): - self.helpers = {skill: load_helpers(skill) for skill in ("docx", "pptx", "xlsx")} - - def assert_rejected(self, data, message): - for skill, helpers in self.helpers.items(): - with self.subTest(skill=skill, message=message), tempfile.TemporaryDirectory() as temp: - data.seek(0) - with zipfile.ZipFile(data) as archive: - with self.assertRaisesRegex(ValueError, message): - helpers.safe_extract(archive, Path(temp)) - - def test_rejects_traversal_absolute_symlink_and_duplicate_targets(self): - self.assert_rejected(archive_bytes([("../escape.txt", b"x")]), "unsafe archive entry") - self.assert_rejected(archive_bytes([("/absolute.txt", b"x")]), "unsafe archive entry") - self.assert_rejected(archive_bytes([(".", b"x")]), "unsafe archive entry") - - symlink = zipfile.ZipInfo("link") - symlink.create_system = 3 - symlink.external_attr = (stat.S_IFLNK | 0o777) << 16 - data = io.BytesIO() - with zipfile.ZipFile(data, "w") as archive: - archive.writestr(symlink, "target") - data.seek(0) - self.assert_rejected(data, "symlink archive entry") - - self.assert_rejected( - archive_bytes([("duplicate.txt", b"a"), ("./duplicate.txt", b"b")]), - "duplicate archive entry", - ) - self.assert_rejected( - archive_bytes([("file", b"a"), ("file/child", b"b")]), - "file entry conflicts with child path", - ) - - def test_rejects_member_count_size_total_size_and_compression_ratio_limits(self): - cases = [ - ("MAX_ARCHIVE_MEMBERS", 1, [("a", b""), ("b", b"")], "too many entries"), - ("MAX_ARCHIVE_MEMBER_SIZE", 1, [("large", b"xx")], "entry is too large"), - ("MAX_ARCHIVE_TOTAL_SIZE", 1, [("total", b"xx")], "allowed total size"), - ] - for constant, limit, entries, message in cases: - for skill, helpers in self.helpers.items(): - with self.subTest(skill=skill, constant=constant), tempfile.TemporaryDirectory() as temp: - original = getattr(helpers, constant) - setattr(helpers, constant, limit) - try: - with zipfile.ZipFile(archive_bytes(entries)) as archive: - with self.assertRaisesRegex(ValueError, message): - helpers.safe_extract(archive, Path(temp)) - finally: - setattr(helpers, constant, original) - - for skill, helpers in self.helpers.items(): - with self.subTest(skill=skill, constant="compression_ratio"), tempfile.TemporaryDirectory() as temp: - original = helpers.MAX_ARCHIVE_COMPRESSION_RATIO - helpers.MAX_ARCHIVE_COMPRESSION_RATIO = 1 - try: - data = archive_bytes([("compressed", b"A" * 4096)], zipfile.ZIP_DEFLATED) - with zipfile.ZipFile(data) as archive: - with self.assertRaisesRegex(ValueError, "unsafe compression ratio"): - helpers.safe_extract(archive, Path(temp)) - finally: - helpers.MAX_ARCHIVE_COMPRESSION_RATIO = original - - def test_extracts_valid_archive(self): - for skill, helpers in self.helpers.items(): - with self.subTest(skill=skill), tempfile.TemporaryDirectory() as temp: - with zipfile.ZipFile(archive_bytes([("word/document.xml", b"")])) as archive: - helpers.safe_extract(archive, Path(temp)) - self.assertEqual( - (Path(temp) / "word" / "document.xml").read_bytes(), - b"", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/src/crates/execution/agent-runtime/src/skills/catalog.rs b/src/crates/execution/agent-runtime/src/skills/catalog.rs index 10cefe85e1..67eda816e8 100644 --- a/src/crates/execution/agent-runtime/src/skills/catalog.rs +++ b/src/crates/execution/agent-runtime/src/skills/catalog.rs @@ -48,10 +48,6 @@ pub(super) const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "create-bitfun-skin", group: BuiltinSkillGroup::Meta, }, - BuiltinSkillSpec { - dir_name: "docx", - group: BuiltinSkillGroup::Office, - }, BuiltinSkillSpec { dir_name: "find-skills", group: BuiltinSkillGroup::Meta, @@ -120,18 +116,10 @@ pub(super) const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "gstack-ship", group: BuiltinSkillGroup::Gstack, }, - BuiltinSkillSpec { - dir_name: "pdf", - group: BuiltinSkillGroup::Office, - }, BuiltinSkillSpec { dir_name: "ppt-design", group: BuiltinSkillGroup::Office, }, - BuiltinSkillSpec { - dir_name: "pptx", - group: BuiltinSkillGroup::Office, - }, BuiltinSkillSpec { dir_name: "pr-review-canvas", group: BuiltinSkillGroup::Canvas, @@ -140,10 +128,6 @@ pub(super) const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "writing-skills", group: BuiltinSkillGroup::Meta, }, - BuiltinSkillSpec { - dir_name: "xlsx", - group: BuiltinSkillGroup::Office, - }, ]; pub(super) fn builtin_skill_spec(dir_name: &str) -> Option<&'static BuiltinSkillSpec> { diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs index 3205b19a18..fb2d18db46 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs @@ -280,7 +280,10 @@ fn project_skill(dir_name: &str) -> SkillInfo { #[test] fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { - assert_eq!(builtin_skill_group_key("docx"), Some("office")); + assert_eq!(builtin_skill_group_key("ppt-design"), Some("office")); + for removed in ["docx", "pdf", "pptx", "xlsx"] { + assert_eq!(builtin_skill_group_key(removed), None); + } assert_eq!(builtin_skill_group_key("create-bitfun-skin"), Some("meta")); assert_eq!(builtin_skill_group_key("find-skills"), Some("meta")); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); @@ -435,15 +438,18 @@ fn user_config_skill_root_resolution_matches_platform_contract() { #[test] fn skill_resolution_applies_builtin_and_user_override_rules() { - let pdf = builtin_skill("pdf"); + let presentation = builtin_skill("ppt-design"); let custom = custom_user_skill("my-custom-skill"); let disabled_project = HashSet::new(); - assert!(!resolve_skill_default_enabled_for_mode(&pdf, "agentic")); + assert!(!resolve_skill_default_enabled_for_mode( + &presentation, + "agentic" + )); assert!(resolve_skill_default_enabled_for_mode(&custom, "agentic")); let default_state = resolve_skill_state_for_mode( - &pdf, + &presentation, "agentic", &UserModeSkillOverrides::default(), &disabled_project, @@ -455,9 +461,9 @@ fn skill_resolution_applies_builtin_and_user_override_rules() { ); let mut overrides = UserModeSkillOverrides::default(); - overrides.enabled_skills.push(pdf.key.clone()); + overrides.enabled_skills.push(presentation.key.clone()); let enabled_state = - resolve_skill_state_for_mode(&pdf, "agentic", &overrides, &disabled_project); + resolve_skill_state_for_mode(&presentation, "agentic", &overrides, &disabled_project); assert!(enabled_state.effective_enabled); assert_eq!( enabled_state.reason, @@ -687,14 +693,14 @@ fn implicit_skill_filter_keeps_explicit_only_skill_out_of_model_catalog() { #[test] fn skill_candidate_key_group_and_resolution_are_runtime_owned() { let markdown = r#"--- -name: pdf -description: Work with PDF files. +name: ppt-design +description: Design presentation slides. --- -Use the pdf workflow. +Use the presentation workflow. "#; let data = SkillData::from_markdown( - "/tmp/bitfun-system/pdf".to_string(), + "/tmp/bitfun-system/ppt-design".to_string(), markdown, SkillLocation::User, false, @@ -703,27 +709,30 @@ Use the pdf workflow. let candidate = SkillCandidate::from_data(data, "bitfun-system", "bitfun", "BitFun", "user", 10, true); - assert_eq!(candidate.info.key, "user::bitfun-system::pdf"); + assert_eq!(candidate.info.key, "user::bitfun-system::ppt-design"); assert_eq!(candidate.info.source_slot, "bitfun-system"); assert_eq!(candidate.info.group_key.as_deref(), Some("office")); - let project_pdf = SkillCandidate { - info: project_skill("pdf"), + let project_presentation = SkillCandidate { + info: project_skill("ppt-design"), priority: 0, }; - let visible = resolve_visible_skills(vec![candidate.clone(), project_pdf.clone()]); + let visible = resolve_visible_skills(vec![candidate.clone(), project_presentation.clone()]); assert_eq!(visible.len(), 1); - assert_eq!(visible[0].key, "project::bitfun::pdf"); + assert_eq!(visible[0].key, "project::bitfun::ppt-design"); - let annotated = sort_skills(annotate_shadowed_skills(vec![candidate, project_pdf])); - let user_pdf = annotated + let annotated = sort_skills(annotate_shadowed_skills(vec![ + candidate, + project_presentation, + ])); + let user_presentation = annotated .iter() - .find(|skill| skill.key == "user::bitfun-system::pdf") + .find(|skill| skill.key == "user::bitfun-system::ppt-design") .expect("user built-in skill should be present"); - assert!(user_pdf.is_shadowed); + assert!(user_presentation.is_shadowed); assert_eq!( - user_pdf.shadowed_by_key.as_deref(), - Some("project::bitfun::pdf") + user_presentation.shadowed_by_key.as_deref(), + Some("project::bitfun::ppt-design") ); } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index c177403d62..27c7017b7d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2477,9 +2477,9 @@ "prompt": "Help me write an email reply.\n\nOriginal email (paste here):\nMy goal (confirm / decline / push forward / clarify):\nTone (formal / friendly / firm but polite):\nKey points to include:\n\nOutput:\n1) Subject line suggestions\n2) Body (2 versions: more formal / more concise)\n3) Questions for the recipient to confirm (if any)" }, "make_docx": { - "title": "Draft a DOCX", - "description": "Write a structured document and export as DOCX.", - "prompt": "Help me write a document and export it as a .docx file.\n\nDocument type (PRD / proposal / meeting summary / report / SOP):\nAudience:\nTone (formal / friendly / concise):\nMust-include points:\nLength target:\n\nDeliverable:\n1) Suggested outline\n2) Full content\n3) Export as a .docx file (save it under artifacts/)" + "title": "Draft a document", + "description": "Turn source material into a structured, reusable document.", + "prompt": "Help me write a structured document.\n\nDocument type (PRD / proposal / meeting summary / report / SOP):\nAudience:\nTone (formal / friendly / concise):\nMust-include points:\nLength target:\n\nDeliverable:\n1) Suggested outline\n2) Full content\n3) Save the Markdown document under artifacts/" }, "make_spreadsheet": { "title": "Design a spreadsheet", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6b670de6cc..9317e0c39a 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2477,9 +2477,9 @@ "prompt": "请帮我写一封邮件回复。\n\n对方邮件内容(可粘贴):\n我的目标(确认/拒绝/推进/澄清):\n语气(正式/友好/强硬但礼貌):\n需要包含的信息点:\n\n请输出:\n1) 主题(Subject)建议\n2) 邮件正文(2 个版本:更正式/更简洁)\n3) 需要对方确认的问题列表(如有)" }, "make_docx": { - "title": "写一份 Word 文档", - "description": "把内容结构化成文档,并导出为 DOCX。", - "prompt": "请帮我写一份文档,并导出为 .docx 文件。\n\n文档类型(PRD/方案/复盘/报告/SOP):\n受众:\n语气(正式/友好/简洁):\n必须包含的信息点:\n期望长度:\n\n最终交付:\n1) 建议目录结构\n2) 完整正文内容\n3) 导出为 DOCX 文件(保存到 artifacts/)" + "title": "写一份结构化文档", + "description": "把资料整理成清晰、可复用的文档。", + "prompt": "请帮我写一份结构化文档。\n\n文档类型(PRD/方案/复盘/报告/SOP):\n受众:\n语气(正式/友好/简洁):\n必须包含的信息点:\n期望长度:\n\n最终交付:\n1) 建议目录结构\n2) 完整正文内容\n3) 将 Markdown 文档保存到 artifacts/" }, "make_spreadsheet": { "title": "做一张表格", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index cea321e2ec..d3b210c9e3 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2477,9 +2477,9 @@ "prompt": "請幫我寫一封郵件回覆。\n\n對方郵件內容(可粘貼):\n我的目標(確認/拒絕/推進/澄清):\n語氣(正式/友好/強硬但禮貌):\n需要包含的資訊點:\n\n請輸出:\n1) 主題(Subject)建議\n2) 郵件正文(2 個版本:更正式/更簡潔)\n3) 需要對方確認的問題列表(如有)" }, "make_docx": { - "title": "寫一份 Word 文檔", - "description": "把內容結構化成文檔,並導出為 DOCX。", - "prompt": "請幫我寫一份文檔,並導出為 .docx 檔案。\n\n文檔類型(PRD/方案/覆盤/報告/SOP):\n受眾:\n語氣(正式/友好/簡潔):\n必須包含的資訊點:\n期望長度:\n\n最終交付:\n1) 建議目錄結構\n2) 完整正文內容\n3) 導出為 DOCX 檔案(儲存到 artifacts/)" + "title": "寫一份結構化文檔", + "description": "把資料整理成清晰、可複用的文檔。", + "prompt": "請幫我寫一份結構化文檔。\n\n文檔類型(PRD/方案/覆盤/報告/SOP):\n受眾:\n語氣(正式/友好/簡潔):\n必須包含的資訊點:\n期望長度:\n\n最終交付:\n1) 建議目錄結構\n2) 完整正文內容\n3) 將 Markdown 文檔儲存到 artifacts/" }, "make_spreadsheet": { "title": "做一張表格", From 7371141fe2b877e15452e4837053d91b8015346c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 05:31:55 -0700 Subject: [PATCH 016/206] fix(ppt-live): inherit model and preserve PDF text --- .../desktop/src/api/miniapp_export_api.rs | 41 +++- .../product-domains/src/miniapp/builtin.rs | 18 +- .../builtin/assets/ppt-live/bundle.json | 2 +- .../builtin/assets/ppt-live/dist/ui.bundle.js | 99 +-------- .../builtin/assets/ppt-live/index.html | 4 - .../miniapp/builtin/assets/ppt-live/meta.json | 10 +- .../ppt-live/src/bitfun-backend-adapter.js | 2 - .../builtin/assets/ppt-live/src/i18n.js | 8 - .../builtin/assets/ppt-live/src/state.js | 14 +- .../test/generated-file-protocol.test.mjs | 27 ++- .../src/miniapp/builtin/assets/ppt-live/ui.js | 88 -------- .../flow_chat/components/ModelSelector.tsx | 12 +- .../components/ModelSelectorExternal.test.tsx | 206 +++++++++++++++++- .../flow_chat/utils/modelSelectionTarget.ts | 19 ++ 14 files changed, 313 insertions(+), 237 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts diff --git a/src/apps/desktop/src/api/miniapp_export_api.rs b/src/apps/desktop/src/api/miniapp_export_api.rs index 2dab358524..91457c8b3e 100644 --- a/src/apps/desktop/src/api/miniapp_export_api.rs +++ b/src/apps/desktop/src/api/miniapp_export_api.rs @@ -19,6 +19,7 @@ const RENDER_TIMEOUT_MS: u64 = 30_000; const RENDER_SETTLE_MS: u64 = 900; /// Reused hidden host — one window, navigate per slide (avoids create/close flash per page). const EXPORT_HOST_LABEL: &str = "miniapp-slide-export-host"; +const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -41,6 +42,13 @@ fn wrap_slide_html(html: &str, width: u32, height: u32) -> String { ) } +fn utf8_html_bytes(html: &str) -> Vec { + let mut bytes = Vec::with_capacity(UTF8_BOM.len() + html.len()); + bytes.extend_from_slice(UTF8_BOM); + bytes.extend_from_slice(html.as_bytes()); + bytes +} + /// Write slide HTML to app cache and return a `file://` URL for the export webview. fn file_url_for_export_html( app: &AppHandle, @@ -54,7 +62,10 @@ fn file_url_for_export_html( std::fs::create_dir_all(&export_dir) .map_err(|error| format!("Failed to create export cache dir: {error}"))?; let file_path = export_dir.join(format!("slide-{}.html", Uuid::new_v4())); - std::fs::write(&file_path, html) + // Sanitized slide documents may intentionally omit author-provided meta + // tags. The BOM makes the file encoding unambiguous before a hidden + // WebView renders it to PDF or PNG. + std::fs::write(&file_path, utf8_html_bytes(html)) .map_err(|error| format!("Failed to write export HTML: {error}"))?; let url = tauri::Url::from_file_path(&file_path) .map_err(|_| "Failed to build file URL for export webview".to_string())?; @@ -164,3 +175,31 @@ pub async fn miniapp_render_slide_page( other => Err(format!("Unsupported slide render format: {other}")), } } + +#[cfg(test)] +mod tests { + use super::{utf8_html_bytes, wrap_slide_html, UTF8_BOM}; + + #[test] + fn export_html_bytes_are_utf8_even_when_full_document_has_no_charset_meta() { + let document = wrap_slide_html( + "架构说明中文 · café", + 1280, + 720, + ); + assert!(!document.to_ascii_lowercase().contains("charset=")); + + let bytes = utf8_html_bytes(&document); + assert!(bytes.starts_with(UTF8_BOM)); + assert_eq!( + std::str::from_utf8(&bytes[UTF8_BOM.len()..]).expect("HTML should remain valid UTF-8"), + document + ); + } + + #[test] + fn fragment_wrapper_keeps_its_explicit_utf8_charset() { + let document = wrap_slide_html("
中文
", 1280, 720); + assert!(document.contains("")); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index cc7d09437a..693aec8c4d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -151,7 +151,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ }, BuiltinMiniAppBundle { id: "builtin-ppt-live", - version: 258, + version: 259, meta_json: include_str!("builtin/assets/ppt-live/meta.json"), html: include_str!("builtin/assets/ppt-live/index.html"), css: include_str!("builtin/assets/ppt-live/style.css"), @@ -549,9 +549,9 @@ mod tests { assert_eq!(meta["version"].as_u64(), Some(u64::from(app.version))); assert_eq!(bundle["version"].as_u64(), Some(u64::from(app.version))); assert_eq!(meta["permissions"]["node"]["enabled"], false); - // AI permission is enabled so the UI can list models for Cowork selection - // via app.ai.getModels(); generation still goes through agent.run. - assert_eq!(meta["permissions"]["ai"]["enabled"], true); + // Model selection belongs to the host's shared ChatInput; PPT Live no + // longer needs raw AI access merely to duplicate the model catalog. + assert!(meta["permissions"].get("ai").is_none()); assert_eq!(meta["permissions"]["agent"]["enabled"], true); assert_eq!(meta["permissions"]["agent"]["rate_limit_per_minute"], 120); // Research happens inside hidden agent turns (WebSearch/WebFetch via @@ -585,7 +585,7 @@ mod tests { // reads the files back instead of parsing giant JSON text. assert!(adapter_source.contains("protocol: 'files'")); assert!(adapter_source.contains("appDataWorkspace: options.appDataWorkspace")); - assert!(adapter_source.contains("model: options.model")); + assert!(!adapter_source.contains("model: options.model")); assert!(adapter_source.contains("displayText: options.displayText")); assert!(app.ui_js.contains("payload?.displayText")); assert!(app @@ -596,8 +596,9 @@ mod tests { let ui_source = include_str!("builtin/assets/ppt-live/ui.js"); assert!(ui_source.contains("backendUsesFileProtocol")); assert!(ui_source.contains("tryReadDeckSlideFile")); - assert!(ui_source.contains("preferredModel")); - assert!(ui_source.contains("modelSelect")); + assert!(!ui_source.contains("preferredModel")); + assert!(!ui_source.contains("modelSelect")); + assert!(!app.html.contains("modelSelect")); assert!(meta["permissions"]["fs"]["read"] .as_array() .is_some_and(|scopes| scopes.iter().any(|scope| scope == "{appdata}"))); @@ -608,9 +609,6 @@ mod tests { assert!( include_str!("builtin/assets/ppt-live/ui.js").contains("installBitFunBackendAdapter") ); - assert!(meta["permissions"]["ai"]["enabled"] - .as_bool() - .unwrap_or(false)); // The single cowork agent turn loads the stable ppt-design skill key. assert!(prompt_source.contains("user::bitfun-system::ppt-design")); let ppt_live_source = include_str!("builtin/assets/ppt-live/ui.js"); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json index 3d0eb3133d..ce2581b985 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "id": "builtin-ppt-live", - "version": 258 + "version": 259 } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js index 3eefde7b47..d7d7c672b8 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js @@ -7031,10 +7031,6 @@ var STRINGS = { propertiesFont: "Font", propertiesColorMode: "Slide colors", propertiesStylePreset: "Style preset", - propertiesModel: "Model", - modelOptionAuto: "Auto (host default)", - modelOptionPrimary: "Primary", - modelOptionFast: "Fast", colorModeLight: "Light", colorModeDark: "Dark", fontSansSerif: "Sans-serif", @@ -7443,10 +7439,6 @@ var STRINGS = { propertiesFont: "\u5B57\u4F53", propertiesColorMode: "\u5E7B\u706F\u7247\u914D\u8272", propertiesStylePreset: "\u98CE\u683C\u9884\u8BBE", - propertiesModel: "\u6A21\u578B", - modelOptionAuto: "\u81EA\u52A8\uFF08\u8DDF\u968F\u4E3B\u673A\u9ED8\u8BA4\uFF09", - modelOptionPrimary: "\u4E3B\u6A21\u578B", - modelOptionFast: "\u5FEB\u901F\u6A21\u578B", colorModeLight: "\u6D45\u8272", colorModeDark: "\u6DF1\u8272", fontSansSerif: "\u975E\u886C\u7EBF", @@ -7743,12 +7735,7 @@ function getAllStylePresets(locale) { // src/state.js var STORAGE_KEY = "pptLiveStudioStateV6"; var HISTORY_KEY = "pptLiveDeckHistoryV1"; -var SCHEMA_VERSION = 6; -var DEFAULT_PREFERRED_MODEL = "primary"; -function normalizePreferredModel(value) { - const raw = String(value || "").trim(); - return raw || DEFAULT_PREFERRED_MODEL; -} +var SCHEMA_VERSION = 7; var ELEMENT_TYPES = ["text", "list", "shape", "metric", "chart", "media"]; var THEME_PRESETS = { executive: { @@ -7906,7 +7893,6 @@ function createInitialState() { runId: "", skillKey: "" }, - preferredModel: DEFAULT_PREFERRED_MODEL, style: defaultStyle(), outline: [], sources: { items: [], facts: [], warnings: [], summary: "", fetchedAt: 0 }, @@ -7947,7 +7933,7 @@ function ensureState(value) { runId: String(state2.agentSession?.runId || ""), skillKey: String(state2.agentSession?.skillKey || "") }; - state2.preferredModel = normalizePreferredModel(state2.preferredModel); + delete state2.preferredModel; state2.style = { ...defaultStyle(), ...state2.style || {} }; delete state2.style.brandPrimary; delete state2.style.brandAccent; @@ -37466,8 +37452,7 @@ function installAgentBackend(app) { return app.agent.ensureSession({ sessionName: "PPT Live", sessionId: options.sessionId, - appDataWorkspace: options.appDataWorkspace, - model: options.model || void 0 + appDataWorkspace: options.appDataWorkspace }); }, async call(action, input, options = {}) { @@ -37480,8 +37465,7 @@ function installAgentBackend(app) { sessionName: "PPT Live", displayText: options.displayText || input.instruction, sessionId: options.sessionId, - appDataWorkspace: options.appDataWorkspace, - model: options.model || void 0 + appDataWorkspace: options.appDataWorkspace }); if (!result?.sessionId || !result?.turnId) { throw new Error("PPT Live agent backend did not return sessionId/turnId"); @@ -38745,8 +38729,7 @@ async function ensureDeckAgentSession() { const project = currentDeckProject() || newDeckProject(); const requestSession = async (sessionId2) => host.backend.ensureSession({ sessionId: sessionId2 || void 0, - appDataWorkspace: project.workspaceSubdir, - model: normalizePreferredModel(state.preferredModel) + appDataWorkspace: project.workspaceSubdir }); let result; const persistedSessionId = String(state.agentSession?.id || ""); @@ -38982,15 +38965,11 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { const progressTracker = createGenerationProgressTracker(); const activity = { lastEventAt: Date.now() }; try { - const preferredModel = normalizePreferredModel( - options.model || state.preferredModel || DEFAULT_PREFERRED_MODEL - ); const result = await host.backend.call("ppt.generate", requestInput, { entityId: "deck", idempotencyKey: `ppt-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId: options.sessionId || void 0, appDataWorkspace: options.appDataWorkspace || void 0, - model: preferredModel, displayText: options.displayText || requestInput.instruction }); sessionId = result?.sessionId || null; @@ -39477,7 +39456,6 @@ async function runCoworkDeckGeneration(operation, instruction, options = {}) { runId: retrySession?.project?.runId || "", skillKey: PPT_DESIGN_SKILL_KEY }; - state.preferredModel = normalizePreferredModel(state.preferredModel); addGenerationEvent({ title: translate("generationParsingDeck"), detail: "", kind: "parsing" }); setGenerationStep("verify", "running", translate("generationVerifyingDeck")); await progressivePublishChain.catch(() => { @@ -40827,24 +40805,6 @@ function bindPropertyPanels() { refreshFlatSelect(stylePresetSelect); }); } - const modelSelect = $("modelSelect"); - if (modelSelect) { - enhanceFlatSelect(modelSelect); - modelSelect.addEventListener("change", () => { - const selected = normalizePreferredModel(modelSelect.value); - if (selected === state.preferredModel) return; - state.preferredModel = selected; - refreshFlatSelect(modelSelect); - void (async () => { - await ensureDeckAgentSession(); - await persist(true); - })().catch((error2) => { - runtime().log?.warn?.("PPT Live failed to prepare the updated model session", { - error: String(error2) - }); - }); - }); - } } var exportPreviewIndex = 0; function getSelectedExportFormat() { @@ -41124,58 +41084,10 @@ function renderStylePresetOptions() { if (stylePresetSelect.selectedIndex < 0) stylePresetSelect.value = DEFAULT_STYLE_PRESET; refreshFlatSelect(stylePresetSelect); } -function appendModelOption(select, value, label) { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - select.append(option); -} -function modelOptionLabel(model) { - const modelName = String(model?.modelName || model?.model_name || "").trim(); - if (modelName) return modelName; - const configName = String(model?.name || "").trim(); - if (configName) return configName; - return String(model?.id || "").trim(); -} -function renderModelOptions(models = []) { - const modelSelect = $("modelSelect"); - if (!modelSelect) return; - const selected = normalizePreferredModel(state.preferredModel); - modelSelect.textContent = ""; - appendModelOption(modelSelect, "auto", translate("modelOptionAuto")); - appendModelOption(modelSelect, "primary", translate("modelOptionPrimary")); - appendModelOption(modelSelect, "fast", translate("modelOptionFast")); - for (const model of Array.isArray(models) ? models : []) { - const id = String(model?.id || "").trim(); - if (!id || id === "auto" || id === "primary" || id === "fast") continue; - appendModelOption(modelSelect, id, modelOptionLabel(model)); - } - if (![...modelSelect.options].some((option) => option.value === selected)) { - appendModelOption(modelSelect, selected, selected); - } - modelSelect.value = selected; - if (modelSelect.selectedIndex < 0) modelSelect.value = DEFAULT_PREFERRED_MODEL; - state.preferredModel = normalizePreferredModel(modelSelect.value); - refreshFlatSelect(modelSelect); -} -async function loadModelOptions() { - renderModelOptions([]); - const getModels = runtime()?.ai?.getModels; - if (typeof getModels !== "function") return; - try { - const models = await getModels(); - renderModelOptions(models); - } catch (error2) { - runtime().log?.warn?.("PPT Live failed to list AI models", { error: String(error2) }); - renderModelOptions([]); - } -} function syncLocale() { state.generation = normalizeGeneration(state.generation); applyI18n(); renderStylePresetOptions(); - renderModelOptions([]); - void loadModelOptions(); syncComposerClaim(); rerender(); } @@ -41207,7 +41119,6 @@ async function init() { syncLocale(); await ensureDeckAgentSession(); syncStylePanelFromState(state); - await loadModelOptions(); await persist(true); } catch (error2) { runtime().log?.error?.("PPT Live init failed", { error: String(error2) }); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html index cc46936d2a..dd116f35aa 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html @@ -111,10 +111,6 @@

PPT Live

-
- Model - -
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json index 3699ed6821..1cf93d3823 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json @@ -10,7 +10,7 @@ "ppt", "ai" ], - "version": 258, + "version": 259, "created_at": 0, "updated_at": 0, "permissions": { @@ -31,18 +31,12 @@ "node": { "enabled": false }, - "ai": { - "enabled": true, - "allowed_models": [], - "max_tokens_per_request": 16000, - "rate_limit_per_minute": 0 - }, "agent": { "enabled": true, "rate_limit_per_minute": 120 } }, - "permission_rationale": "PPT Live stores only its own deck draft, lists available AI models so the user can choose which Cowork model generates decks, fetches user-provided URLs only when generating source-grounded decks, runs hidden BitFun agent turns (ppt-design skill plus research tools such as WebSearch/WebFetch) only when the user asks it to generate or refine content, and exports PPTX/PDF/PNG/HTML entirely inside the desktop WebView.", + "permission_rationale": "PPT Live stores only its own deck draft, runs hidden BitFun agent turns (ppt-design skill plus research tools such as WebSearch/WebFetch) only when the user asks it to generate or refine content, uses the model selected in the host's shared floating chat, and exports PPTX/PDF/PNG/HTML entirely inside the desktop WebView.", "ai_context": { "original_prompt": "A built-in Live App for AI-assisted PPT generation, preview, and visual editing.", "conversation_id": null, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js index 6717f50ed4..a072b2b783 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js @@ -43,7 +43,6 @@ function installAgentBackend(app) { sessionName: 'PPT Live', sessionId: options.sessionId, appDataWorkspace: options.appDataWorkspace, - model: options.model || undefined, }); }, async call(action, input, options = {}) { @@ -57,7 +56,6 @@ function installAgentBackend(app) { displayText: options.displayText || input.instruction, sessionId: options.sessionId, appDataWorkspace: options.appDataWorkspace, - model: options.model || undefined, }); if (!result?.sessionId || !result?.turnId) { throw new Error('PPT Live agent backend did not return sessionId/turnId'); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js index eef63e2e24..17bdfc6ef4 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js @@ -397,10 +397,6 @@ export const STRINGS = { propertiesFont: 'Font', propertiesColorMode: 'Slide colors', propertiesStylePreset: 'Style preset', - propertiesModel: 'Model', - modelOptionAuto: 'Auto (host default)', - modelOptionPrimary: 'Primary', - modelOptionFast: 'Fast', colorModeLight: 'Light', colorModeDark: 'Dark', fontSansSerif: 'Sans-serif', @@ -809,10 +805,6 @@ export const STRINGS = { propertiesFont: '字体', propertiesColorMode: '幻灯片配色', propertiesStylePreset: '风格预设', - propertiesModel: '模型', - modelOptionAuto: '自动(跟随主机默认)', - modelOptionPrimary: '主模型', - modelOptionFast: '快速模型', colorModeLight: '浅色', colorModeDark: '深色', fontSansSerif: '非衬线', diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js index 7df55d3db8..297cfc3b27 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js @@ -3,14 +3,7 @@ import { normalizeStylePresetKey } from './style-presets.js'; export const STORAGE_KEY = 'pptLiveStudioStateV6'; export const HISTORY_KEY = 'pptLiveDeckHistoryV1'; -export const SCHEMA_VERSION = 6; -/** Default Cowork model selector when the user has not chosen one yet. */ -export const DEFAULT_PREFERRED_MODEL = 'primary'; - -export function normalizePreferredModel(value) { - const raw = String(value || '').trim(); - return raw || DEFAULT_PREFERRED_MODEL; -} +export const SCHEMA_VERSION = 7; export const ELEMENT_TYPES = ['text', 'list', 'shape', 'metric', 'chart', 'media']; export const THEME_PRESETS = { @@ -189,7 +182,6 @@ export function createInitialState() { runId: '', skillKey: '', }, - preferredModel: DEFAULT_PREFERRED_MODEL, style: defaultStyle(), outline: [], sources: { items: [], facts: [], warnings: [], summary: '', fetchedAt: 0 }, @@ -231,7 +223,9 @@ export function ensureState(value) { runId: String(state.agentSession?.runId || ''), skillKey: String(state.agentSession?.skillKey || ''), }; - state.preferredModel = normalizePreferredModel(state.preferredModel); + // Model selection belongs to the host ChatInput. Remove the legacy field so + // restored decks cannot overwrite the model selected in the floating chat. + delete state.preferredModel; state.style = { ...defaultStyle(), ...(state.style || {}) }; delete state.style.brandPrimary; delete state.style.brandAccent; diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs index 9f628e8186..88cab8f367 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs @@ -178,7 +178,7 @@ test('prompt pins the stable skill key and workspace-relative delivery contract' assert.match(prompt, /禁止.*Read references\/style-presets/); }); -test('backend adapter ensures a topic session and forwards preferred model into agent.run options', async () => { +test('backend adapter reuses the topic session without overriding the host-selected model', async () => { const { installBitFunBackendAdapter } = await import('../src/bitfun-backend-adapter.js'); const ensureCalls = []; const calls = []; @@ -219,15 +219,36 @@ test('backend adapter ensures a topic session and forwards preferred model into sessionName: 'PPT Live', sessionId: 's1', appDataWorkspace: 'decks/demo', - model: 'fast', }]); assert.equal(calls.length, 1); - assert.equal(calls[0].model, 'fast'); + assert.equal(Object.hasOwn(calls[0], 'model'), false); assert.equal(calls[0].sessionId, 's1'); assert.equal(calls[0].appDataWorkspace, 'decks/demo'); assert.equal(calls[0].displayText, '随便做几页测试页'); }); +test('legacy model state is discarded and PPT Live no longer renders its own selector', async () => { + const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { app: { locale: 'en-US' } }; + globalThis.document = { documentElement: { lang: 'en-US' } }; + + let restored; + try { + const { ensureState } = await import('../src/state.js'); + restored = ensureState({ preferredModel: 'fast' }); + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } + + assert.equal(Object.hasOwn(restored, 'preferredModel'), false); + assert.doesNotMatch(html, /modelSelect|propertiesModel/); +}); + test('PPT topic lifecycle eagerly creates or rebinds its dedicated session', async () => { const uiSource = await readFile(new URL('../ui.js', import.meta.url), 'utf8'); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js index 38c80bdcee..af877fbb11 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js @@ -30,8 +30,6 @@ import { densityToIndex, indexToDensity, uid, - DEFAULT_PREFERRED_MODEL, - normalizePreferredModel, } from './src/state.js'; import { getAllStylePresets, getStylePreset, DEFAULT_STYLE_PRESET, resolveStylePalette } from './src/style-presets.js'; import { enhanceFlatSelect, refreshFlatSelect } from './src/flat-select.js'; @@ -835,7 +833,6 @@ async function ensureDeckAgentSession() { const requestSession = async (sessionId) => host.backend.ensureSession({ sessionId: sessionId || undefined, appDataWorkspace: project.workspaceSubdir, - model: normalizePreferredModel(state.preferredModel), }); let result; @@ -1149,15 +1146,11 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { const activity = { lastEventAt: Date.now() }; try { - const preferredModel = normalizePreferredModel( - options.model || state.preferredModel || DEFAULT_PREFERRED_MODEL, - ); const result = await host.backend.call('ppt.generate', requestInput, { entityId: 'deck', idempotencyKey: `ppt-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId: options.sessionId || undefined, appDataWorkspace: options.appDataWorkspace || undefined, - model: preferredModel, displayText: options.displayText || requestInput.instruction, }); sessionId = result?.sessionId || null; @@ -1731,8 +1724,6 @@ async function runCoworkDeckGeneration(operation, instruction, options = {}) { runId: retrySession?.project?.runId || '', skillKey: PPT_DESIGN_SKILL_KEY, }; - state.preferredModel = normalizePreferredModel(state.preferredModel); - // The agent delivered through files; read them back. addGenerationEvent({ title: t('generationParsingDeck'), detail: '', kind: 'parsing' }); setGenerationStep('verify', 'running', t('generationVerifyingDeck')); @@ -3437,27 +3428,6 @@ function bindPropertyPanels() { }); } - /* Cowork model selector */ - const modelSelect = $('modelSelect'); - if (modelSelect) { - enhanceFlatSelect(modelSelect); - modelSelect.addEventListener('change', () => { - const selected = normalizePreferredModel(modelSelect.value); - if (selected === state.preferredModel) return; - state.preferredModel = selected; - refreshFlatSelect(modelSelect); - void (async () => { - // Keep the topic's conversation intact; ensureSession updates the - // persisted session's model in place. - await ensureDeckAgentSession(); - await persist(true); - })().catch((error) => { - runtime().log?.warn?.('PPT Live failed to prepare the updated model session', { - error: String(error), - }); - }); - }); - } } /* ============================================ @@ -3760,67 +3730,10 @@ function renderStylePresetOptions() { refreshFlatSelect(stylePresetSelect); } -function appendModelOption(select, value, label) { - const option = document.createElement('option'); - option.value = value; - option.textContent = label; - select.append(option); -} - -/** Match host chat ModelSelector: concrete options use model_name. */ -function modelOptionLabel(model) { - const modelName = String(model?.modelName || model?.model_name || '').trim(); - if (modelName) return modelName; - const configName = String(model?.name || '').trim(); - if (configName) return configName; - return String(model?.id || '').trim(); -} - -function renderModelOptions(models = []) { - const modelSelect = $('modelSelect'); - if (!modelSelect) return; - const selected = normalizePreferredModel(state.preferredModel); - modelSelect.textContent = ''; - - // Same special entries as chat ModelSelector: auto / primary / fast, then concrete models. - appendModelOption(modelSelect, 'auto', t('modelOptionAuto')); - appendModelOption(modelSelect, 'primary', t('modelOptionPrimary')); - appendModelOption(modelSelect, 'fast', t('modelOptionFast')); - - for (const model of Array.isArray(models) ? models : []) { - const id = String(model?.id || '').trim(); - if (!id || id === 'auto' || id === 'primary' || id === 'fast') continue; - appendModelOption(modelSelect, id, modelOptionLabel(model)); - } - - if (![...modelSelect.options].some((option) => option.value === selected)) { - appendModelOption(modelSelect, selected, selected); - } - modelSelect.value = selected; - if (modelSelect.selectedIndex < 0) modelSelect.value = DEFAULT_PREFERRED_MODEL; - state.preferredModel = normalizePreferredModel(modelSelect.value); - refreshFlatSelect(modelSelect); -} - -async function loadModelOptions() { - renderModelOptions([]); - const getModels = runtime()?.ai?.getModels; - if (typeof getModels !== 'function') return; - try { - const models = await getModels(); - renderModelOptions(models); - } catch (error) { - runtime().log?.warn?.('PPT Live failed to list AI models', { error: String(error) }); - renderModelOptions([]); - } -} - function syncLocale() { state.generation = normalizeGeneration(state.generation); applyI18n(); renderStylePresetOptions(); - renderModelOptions([]); - void loadModelOptions(); syncComposerClaim(); rerender(); } @@ -3859,7 +3772,6 @@ async function init() { syncLocale(); await ensureDeckAgentSession(); syncStylePanelFromState(state); - await loadModelOptions(); await persist(true); } catch (error) { runtime().log?.error?.('PPT Live init failed', { error: String(error) }); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 6016c4666d..23ac5aee3e 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -47,6 +47,10 @@ import { getRecentReasoningPreset, setRecentReasoningPreset, } from '../utils/reasoningPresets'; +import { + shouldIncludeInternalModelSession, + shouldSyncSessionModelSelection, +} from '../utils/modelSelectionTarget'; import './ModelSelector.scss'; const log = createLogger('ModelSelector'); @@ -708,7 +712,7 @@ export const ModelSelector: React.FC = ({ const maxContextTokens = await getModelMaxTokens(modelId, currentMode); store.updateSessionMaxContextTokens(sessionId, maxContextTokens); const session = store.getState().sessions.get(sessionId); - if (session && !session.isTransient) { + if (shouldSyncSessionModelSelection(session)) { await agentAPI.updateSessionModel({ sessionId, modelName: modelId, @@ -716,7 +720,7 @@ export const ModelSelector: React.FC = ({ workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', + includeInternal: shouldIncludeInternalModelSession(session), }); } }; @@ -799,7 +803,7 @@ export const ModelSelector: React.FC = ({ setReasoningLoading(true); store.updateSessionReasoningPreset(sessionId, normalizedPreset); try { - if (!session.isTransient) { + if (shouldSyncSessionModelSelection(session)) { await agentAPI.updateSessionModel({ sessionId, modelName: currentNativeModelId, @@ -807,7 +811,7 @@ export const ModelSelector: React.FC = ({ workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', + includeInternal: shouldIncludeInternalModelSession(session), }); } if (!targetIsSubagent) { diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 3d56e93b84..563ecfa1f6 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -7,6 +7,12 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelSelector } from './ModelSelector'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { setRecentReasoningPreset } from '../utils/reasoningPresets'; +import { + shouldIncludeInternalModelSession, + shouldSyncSessionModelSelection, +} from '../utils/modelSelectionTarget'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -15,6 +21,52 @@ const aiApiMocks = vi.hoisted(() => ({ onModelCatalogUpdated: vi.fn(), })); +const flowChatStoreMocks = vi.hoisted(() => { + type TestSession = { + sessionKind?: string; + isTransient?: boolean; + agentBackedTransient?: boolean; + workspacePath?: string; + projectWorkspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + maxContextTokens?: number; + config: { + agentType?: string; + modelName?: string; + reasoningPreset?: string; + workspacePath?: string; + projectWorkspacePath?: string; + }; + }; + const sessions = new Map(); + const subscribers = new Set<() => void>(); + const emit = () => subscribers.forEach(callback => callback()); + const store = { + getState: () => ({ sessions }), + subscribe: vi.fn((callback: () => void) => { + subscribers.add(callback); + return () => subscribers.delete(callback); + }), + updateSessionModelName: vi.fn((sessionId: string, modelName: string) => { + const session = sessions.get(sessionId); + if (session) session.config.modelName = modelName; + emit(); + }), + updateSessionReasoningPreset: vi.fn((sessionId: string, reasoningPreset?: string) => { + const session = sessions.get(sessionId); + if (session) session.config.reasoningPreset = reasoningPreset; + emit(); + }), + updateSessionMaxContextTokens: vi.fn((sessionId: string, maxContextTokens: number) => { + const session = sessions.get(sessionId); + if (session) session.maxContextTokens = maxContextTokens; + }), + updateAcpContextUsage: vi.fn(), + }; + return { sessions, subscribers, store }; +}); + vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ aiApi: aiApiMocks, })); @@ -65,6 +117,10 @@ vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ }, })); +vi.mock('../services/flow-chat-manager/SessionModule', () => ({ + getModelMaxTokens: vi.fn(async () => 128_000), +})); + vi.mock('@/infrastructure/event-bus', () => ({ globalEventBus: { emit: vi.fn(), @@ -75,10 +131,7 @@ vi.mock('@/infrastructure/event-bus', () => ({ vi.mock('../store/FlowChatStore', () => ({ FlowChatStore: { - getInstance: () => ({ - getState: () => ({ sessions: new Map() }), - subscribe: () => () => undefined, - }), + getInstance: () => flowChatStoreMocks.store, }, })); @@ -89,6 +142,18 @@ describe('ModelSelector external transport reuse', () => { beforeEach(() => { catalogUpdated = undefined; + flowChatStoreMocks.sessions.clear(); + flowChatStoreMocks.subscribers.clear(); + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, String(value)), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { return storage.size; }, + }); + window.localStorage.clear(); aiApiMocks.getModelCatalog.mockResolvedValue({ version: 1, default_models: { primary: 'model-a' }, @@ -117,6 +182,139 @@ describe('ModelSelector external transport reuse', () => { vi.clearAllMocks(); }); + it('syncs agent-backed transient selections to their hidden runtime session', () => { + const miniAppSession = { + sessionKind: 'miniapp', + isTransient: true, + agentBackedTransient: true, + }; + + expect(shouldSyncSessionModelSelection(miniAppSession)).toBe(true); + expect(shouldIncludeInternalModelSession(miniAppSession)).toBe(true); + expect(shouldSyncSessionModelSelection({ isTransient: true })).toBe(false); + }); + + it('updates an agent-backed transient session when its model and reasoning change', async () => { + flowChatStoreMocks.sessions.set('miniapp-session', { + sessionKind: 'miniapp', + isTransient: true, + agentBackedTransient: true, + workspacePath: '/tmp/miniapp-runtime', + projectWorkspacePath: '/tmp/project', + config: { + agentType: 'agentic', + modelName: 'model-a', + reasoningPreset: 'medium', + }, + }); + vi.mocked(configManager.getConfigs).mockResolvedValueOnce({ + 'ai.models': [ + { + id: 'model-a', + name: 'Model A', + model_name: 'model-a-native', + provider: 'openai', + base_url: 'https://example.test/v1', + enabled: true, + category: 'text', + capabilities: ['text_chat'], + }, + { + id: 'model-b', + name: 'Model B', + model_name: 'model-b-native', + provider: 'openai', + base_url: 'https://example.test/v1', + enabled: true, + category: 'text', + capabilities: ['text_chat'], + }, + ], + 'ai.default_models': { primary: 'model-a' }, + 'ai.agent_model_defaults': { mode: 'model-a' }, + }); + aiApiMocks.getModelCatalog.mockResolvedValueOnce({ + version: 1, + default_models: { primary: 'model-a' }, + models: ['model-a', 'model-b'].map(id => ({ + id, + name: id, + provider: 'openai', + base_url: 'https://example.test/v1', + model_name: `${id}-native`, + enabled: true, + capabilities: ['text_chat'], + reasoning: { + status: 'known', + default_preset: 'medium', + presets: [ + { + id: 'medium', + label: 'Medium', + order: 10, + source: 'models_dev', + actions: [{ type: 'effort', value: 'medium' }], + }, + { + id: 'high', + label: 'High', + order: 20, + source: 'models_dev', + actions: [{ type: 'effort', value: 'high' }], + }, + ], + }, + })), + }); + setRecentReasoningPreset('model-b', 'medium'); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + await act(async () => { + container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + }); + await act(async () => { + document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="model-b"]', + )?.click(); + await Promise.resolve(); + }); + + expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ + sessionId: 'miniapp-session', + modelName: 'model-b', + reasoningPreset: 'medium', + workspacePath: '/tmp/project', + includeInternal: true, + })); + + await act(async () => { + container.querySelector( + '[data-testid="chat-reasoning-preset-selector-btn"]', + )?.click(); + }); + await act(async () => { + document.body.querySelector('[data-preset-id="high"]')?.click(); + await Promise.resolve(); + }); + + expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ + sessionId: 'miniapp-session', + modelName: 'model-b', + reasoningPreset: 'high', + workspacePath: '/tmp/project', + includeInternal: true, + })); + }); + it('reloads the local catalog when the backend reports a snapshot update', async () => { const updatedCatalog = { version: 2, diff --git a/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts b/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts new file mode 100644 index 0000000000..f6c937c259 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts @@ -0,0 +1,19 @@ +export type SessionModelSelectionTarget = { + isTransient?: boolean; + agentBackedTransient?: boolean; + sessionKind?: string; +}; + +/** Whether the visible selector has a real runtime session to update. */ +export function shouldSyncSessionModelSelection( + session: T | undefined, +): session is T { + return Boolean(session && (!session.isTransient || session.agentBackedTransient)); +} + +/** Whether restoring the target requires access to internal runtime sessions. */ +export function shouldIncludeInternalModelSession( + session: SessionModelSelectionTarget | undefined, +): boolean { + return Boolean(session?.sessionKind === 'subagent' || session?.agentBackedTransient); +} From e99b206457e697f304168f1b74df7dbfc7ef7954 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 3 Aug 2026 15:46:27 +0800 Subject: [PATCH 017/206] feat(harmonyos): improve adaptive chat and file preview --- src/apps/mobile/harmonyos/README.md | 6 +- .../main/ets/entryability/EntryAbility.ets | 29 +- .../entry/src/main/ets/i18n/RemoteI18n.ets | 56 +- .../entry/src/main/ets/model/RemoteModels.ets | 9 + .../entry/src/main/ets/pages/AppRoot.ets | 5 + .../pages/components/AppRootPresentation.ets | 748 ++++++++++++--- .../main/ets/pages/components/AppShell.ets | 146 ++- .../main/ets/pages/components/AppSidebar.ets | 497 ++++++---- .../components/BitFunAccountLoginPage.ets | 19 +- .../pages/components/ChatMessageBubble.ets | 274 ++---- .../ets/pages/components/ChatStatusBar.ets | 6 +- .../ets/pages/components/ChatTimeline.ets | 20 +- .../main/ets/pages/components/ComposerBar.ets | 403 +++++++- .../main/ets/pages/components/ConnectView.ets | 87 +- .../pages/components/ConversationIntent.ets | 18 +- .../components/ConversationSourceSwitcher.ets | 8 +- .../ets/pages/components/ConversationView.ets | 294 ++++-- .../pages/components/ConversationViewHost.ets | 16 +- .../components/ConversationViewSettings.ets | 397 ++++++++ .../pages/components/CreateSessionSheet.ets | 6 +- .../pages/components/DefaultAccountAvatar.ets | 7 +- .../pages/components/FilePreviewSurface.ets | 523 +++++++++++ .../pages/components/FileReferenceCard.ets | 95 +- .../pages/components/GeneralChatHeader.ets | 142 +-- .../ets/pages/components/MarkdownContent.ets | 27 +- .../components/ModelServiceSettingsPanel.ets | 479 ++++++++-- .../pages/components/RemoteActionsSheet.ets | 35 +- .../ets/pages/components/RemoteBottomBar.ets | 20 +- .../ets/pages/components/RemoteChatHeader.ets | 292 ++---- .../components/RemoteControlSettingsSheet.ets | 130 +-- .../components/RemoteCreateSessionView.ets | 273 ++++-- .../ets/pages/components/RemoteHeader.ets | 29 +- .../ets/pages/components/RemoteHomeView.ets | 113 ++- .../pages/components/RemoteSessionList.ets | 460 +++++++--- .../pages/components/SessionActionSurface.ets | 192 ++++ .../pages/components/SessionDetailsView.ets | 149 +++ .../ets/pages/components/SettingsSheet.ets | 83 +- .../pages/components/SidebarToggleButton.ets | 49 + .../components/StreamingMarkdownContent.ets | 4 + .../src/main/ets/pages/components/Theme.ets | 38 +- .../ets/pages/components/ToolStatusList.ets | 199 ++-- .../ets/pages/host/AppRootHostAdapter.ets | 16 + .../main/ets/pages/state/AppRootRuntime.ets | 309 ++++++- .../main/ets/pages/state/AppShellState.ets | 24 +- .../ets/pages/state/AppShellViewModel.ets | 3 + .../state/ConversationIntentDispatcher.ets | 10 +- .../pages/state/ConversationLayoutPolicy.ets | 58 +- .../ConversationModelPresentationPolicy.ets | 82 ++ .../state/ConversationSessionFilterPolicy.ets | 51 ++ .../ets/pages/state/ConversationViewState.ets | 2 + .../state/FilePreviewPlacementPolicy.ets | 185 ++++ .../main/ets/pages/state/FilePreviewState.ets | 107 +++ .../ets/pages/state/FilePreviewTarget.ets | 62 ++ .../ets/pages/state/GeneralChatPageState.ets | 20 +- .../pages/state/RemoteCreateSessionState.ets | 28 + .../pages/state/RemoteSessionViewModel.ets | 23 +- .../ets/pages/state/SessionActionPolicy.ets | 31 + .../main/ets/services/CloudAccountClient.ets | 30 + .../ets/services/CodeSyntaxHighlighter.ets | 422 +++++++++ .../ets/services/FilePreviewErrorPolicy.ets | 42 + .../main/ets/services/FilePreviewPolicy.ets | 13 + .../main/ets/services/FileTargetResolver.ets | 155 ++++ .../src/main/ets/services/MarkdownParser.ets | 16 + .../MessageFileReferenceProjector.ets | 103 +++ .../services/RemoteFileDownloadController.ets | 15 +- .../services/RemoteFilePreviewController.ets | 266 ++++++ .../ets/services/RemoteSessionManager.ets | 33 +- .../services/RemoteWorkspaceFileClient.ets | 12 + .../services/ToolFileReferenceResolver.ets | 66 ++ .../GeneralChatCloudConfigPolicy.ets | 119 +++ .../general-chat/GeneralChatConfigStore.ets | 121 +++ .../ModelProviderGeneralChatAdapter.ets | 8 +- .../harmonyos/entry/src/main/module.json5 | 3 +- .../main/resources/base/element/color.json | 114 ++- .../main/resources/dark/element/color.json | 114 ++- .../src/test/AppRootLifecycleUnit.test.ets | 80 +- .../entry/src/test/ArchitectureUnit.test.ets | 8 + .../entry/src/test/LifecycleUnit.test.ets | 7 + .../entry/src/test/LocalTestFixtures.ets | 49 +- .../src/test/RemoteControllersUnit.test.ets | 859 +++++++++++++++++- .../test/TransportAndGeneralChatUnit.test.ets | 71 +- 81 files changed, 8368 insertions(+), 1752 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index 03c10dfea5..dd852c97a3 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -1,7 +1,7 @@ # BitFun HarmonyOS -Native HarmonyOS phone client for BitFun. The application provides general -chat and remote control of BitFun desktop sessions. +Native HarmonyOS client for BitFun. The application provides general chat and +remote control of BitFun desktop sessions on phone and tablet devices. ## Project Layout @@ -29,4 +29,4 @@ Signing configuration is intentionally not stored in the repository. Configure a local signing identity in DevEco Studio when installing the app on a device. The current project targets HarmonyOS `6.1.1(24)` and supports -`6.0.1(21)` or newer on phone devices. +`6.0.1(21)` or newer on phone and tablet devices. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index e2829c2156..c032190ac6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { AbilityConstant, Configuration, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; @@ -6,6 +6,8 @@ const DOMAIN = 0x0000; const TAG = 'BitFunRemote'; export default class EntryAbility extends UIAbility { + private mainWindow?: window.Window; + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); @@ -24,13 +26,9 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate'); try { const mainWindow = windowStage.getMainWindowSync(); + this.mainWindow = mainWindow; mainWindow.setWindowLayoutFullScreen(false); - mainWindow.setWindowSystemBarProperties({ - statusBarColor: '#FAFAF8', - navigationBarColor: '#FAFAF8', - statusBarContentColor: '#171717', - navigationBarContentColor: '#171717' - }); + this.updateSystemBars(this.context.config?.colorMode); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); } @@ -54,8 +52,25 @@ export default class EntryAbility extends UIAbility { hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground'); } + onConfigurationUpdate(newConfig: Configuration): void { + this.updateSystemBars(newConfig.colorMode); + } + onBackground(): void { // Ability has back to background hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground'); } + + private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { + if (!this.mainWindow) { + return; + } + const dark = colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; + this.mainWindow.setWindowSystemBarProperties({ + statusBarColor: dark ? '#151514' : '#FDFDFB', + navigationBarColor: dark ? '#151514' : '#FDFDFB', + statusBarContentColor: dark ? '#F4F3EF' : '#171717', + navigationBarContentColor: dark ? '#F4F3EF' : '#171717' + }); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index deb13d538c..f55c73b6ab 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -2,6 +2,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['app.title', 'BitFun'], ['common.cancel', '取消'], + ['common.back', '返回'], ['common.close', '关闭'], ['common.copy', '复制'], ['common.current', '当前'], @@ -48,6 +49,7 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['generalChat.interrupted', '回复已中断,已保留收到的内容。'], ['generalChat.replyInterrupted', '回复中断'], ['generalChat.fileDownloadMock', '普通聊天暂不支持下载桌面端文件,请进入 Code 后处理本地文件。'], + ['generalChat.filePreviewUnavailable', '普通聊天暂不支持预览桌面端文件,请进入 Code 后打开。'], ['generalChat.localRestoreFailed', '普通对话历史暂时无法恢复,你仍可新建对话。'], ['generalChat.modelNotConfigured', '请先在设置中配置普通对话模型。'], ['generalChat.imageNotSupported', '当前模型通道暂不支持图片,请先发送文字消息。'], @@ -58,12 +60,22 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['generalChat.emptyResponse', '模型没有返回文字内容。'], ['generalChat.requestFailed', '模型请求失败(HTTP {0})。'], - ['settings.modelService.section', '模型服务'], + ['settings.modelService.section', '普通对话'], ['settings.title', '设置'], ['settings.about.section', '关于'], ['settings.about.product', '产品'], ['settings.about.version', '版本'], - ['settings.modelService.title', '普通对话模型'], + ['settings.modelService.title', '模型'], + ['settings.modelService.manageTitle', '普通对话模型'], + ['settings.modelService.localTitle', '本机自定义模型'], + ['settings.modelService.currentModel', '当前使用'], + ['settings.modelService.accountModels', '账号同步'], + ['settings.modelService.accountModelSummary', '云端账号模型'], + ['settings.modelService.localModel', '本机自定义'], + ['settings.modelService.accountSource', '云端账号'], + ['settings.modelService.localSource', '本机'], + ['settings.modelService.syncedCount', '已同步 {0} 个'], + ['settings.modelService.accountEmpty', '暂无可用的账号模型'], ['settings.modelService.configured', '已配置'], ['settings.modelService.notConfigured', '未配置'], ['settings.modelService.apiUrl', 'API URL'], @@ -107,6 +119,31 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', '没有匹配的会话'], ['sidebar.signedOutNewChat', '新聊天'], ['sidebar.signInBitFunAccount', '登录BitFun账号'], + ['sidebar.collapse', '收起侧边栏'], + ['sidebar.restore', '展开侧边栏'], + ['session.actions', '会话操作'], + ['session.details', '会话详情'], + ['session.viewDetails', '查看详情'], + ['session.agentType', 'Agent 类型'], + ['session.workspace', '工作区'], + ['session.workspacePath', '工作区路径'], + ['session.createdAt', '创建时间'], + ['session.updatedAt', '更新时间'], + ['session.messageCount', '消息数'], + ['session.status', '状态'], + ['viewSettings.title', '视图设置'], + ['viewSettings.subtitle', '调整会话列表的分组和信息密度'], + ['viewSettings.grouping', '分组方式'], + ['viewSettings.filters', '筛选'], + ['viewSettings.metadata', '显示信息'], + ['viewSettings.workspace', '工作区'], + ['viewSettings.agentType', '会话类型'], + ['viewSettings.allWorkspaces', '全部工作区'], + ['viewSettings.allAgentTypes', '全部类型'], + ['viewSettings.allStatuses', '全部状态'], + ['viewSettings.updated', '更新时间'], + ['viewSettings.status', '运行状态'], + ['common.unknown', '未知'], ['code.title', 'BitFun Code'], ['code.heroTitle', '本地开发助手'], @@ -131,12 +168,14 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.actions', '远程设置'], ['remote.device', '连接的设备'], ['remote.newChat', '聊天'], + ['remote.create.title', '新建任务'], ['remote.create.chat', '聊天'], ['remote.create.noDevice', '选择桌面设备'], ['remote.create.noOnlineDevice', '没有可用的在线桌面设备'], ['remote.create.placeholder', '告诉 BitFun 要做什么'], ['remote.create.deviceLoadFailed', '设备列表加载失败,请稍后重试。'], ['remote.create.workspaceLoadFailed', '工作区加载失败,请稍后重试。'], + ['remote.create.deviceMismatch', '所选设备尚未连接,请重新选择设备后再试。'], ['remote.create.submitFailed', '无法创建会话,请检查桌面连接后重试。'], ['remote.workspace', '工作区'], ['remote.assistant', '助理'], @@ -397,6 +436,19 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['chat.fileLink', '文件链接'], ['chat.reading', '读取中'], ['chat.download', '下载'], + ['filePreview.title', '文件预览'], + ['filePreview.loading', '正在读取文件'], + ['filePreview.offline', '离线,仅显示已加载内容'], + ['filePreview.fitImage', '适应窗口'], + ['filePreview.actualImageSize', '原始大小'], + ['filePreview.imageDecodeFailed', '图片无法解码,请重试或下载文件。'], + ['filePreview.loadFailed', '无法打开文件'], + ['filePreview.notFound', '文件不存在或已被移动'], + ['filePreview.unavailable', '文件不存在或不在当前工作区'], + ['filePreview.accessDenied', '无法访问工作区外的文件'], + ['filePreview.tooLarge', '文件过大,无法在移动端预览'], + ['filePreview.unsupported', '此文件暂不支持预览'], + ['filePreview.truncated', '已显示前 {0},下载后可查看完整内容'], ['chat.pendingConfirmation', '待确认'], ['chat.cancelled', '已取消'], ['chat.jsonObjectRequired', '请输入 JSON 对象'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index ff359d2a89..ca785d95ed 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -453,3 +453,12 @@ export interface ReadFileResult { mimeType: string; size: number; } + +export interface ReadFileChunkResult { + name: string; + contentBase64: string; + offset: number; + chunkSize: number; + totalSize: number; + mimeType: string; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index a2de3319da..cb27247172 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -25,6 +25,10 @@ struct AppRoot { this.runtime.aboutToDisappear(); } + onBackPress(): boolean { + return this.runtime.handleRootBack(); + } + build() { Stack() { AppRootPresentation({ @@ -33,6 +37,7 @@ struct AppRoot { remotePageState: this.runtime.remotePageState, remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, + filePreviewState: this.runtime.filePreviewState, deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), currentRoute: this.runtime.currentRoute(), actions: this.runtime.presentationActions diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index e63230a8c4..d34498c0c5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,20 +1,29 @@ import display from '@ohos.display'; +import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; import { AppSidebar } from './AppSidebar'; import { ConnectView } from './ConnectView'; import { ConversationIntent } from './ConversationIntent'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationViewSettings } from './ConversationViewSettings'; import { ConversationViewHost } from './ConversationViewHost'; +import { FilePreviewSurface } from './FilePreviewSurface'; import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; import { RemoteCreateSessionView } from './RemoteCreateSessionView'; import { RemoteHomeView } from './RemoteHomeView'; import { RemoteSessionList } from './RemoteSessionList'; +import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { SessionActionPresentation } from './SessionActionSurface'; import { SettingsSheet } from './SettingsSheet'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED } from './Theme'; +import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; import { AppShellState } from '../state/AppShellState'; import { @@ -25,6 +34,12 @@ import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { ConversationViewState } from '../state/ConversationViewState'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { + FilePreviewLayout, + FilePreviewPlacement, + FilePreviewPlacementPolicy +} from '../state/FilePreviewPlacementPolicy'; const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; @@ -36,16 +51,26 @@ function safeFoldStatus(): display.FoldStatus { } } +function safeDeviceType(): string { + try { + return deviceInfo.deviceType || ''; + } catch (_err) { + return ''; + } +} + export class AppRootPresentationActions { readonly onNavigationBack: (route: AppRoute) => boolean; readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; readonly onCloseSidebar: () => void; readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactLayoutEntered: () => void; readonly onRemoteHome: RemoteHomePresentationActions; readonly onRemoteCreate: RemoteCreatePresentationActions; readonly onSidebar: SidebarPresentationActions; readonly onSettings: SettingsPresentationActions; readonly onConnect: ConnectPresentationActions; + readonly onFilePreview: FilePreviewPresentationActions; readonly generalStatus: () => string; constructor( @@ -53,26 +78,49 @@ export class AppRootPresentationActions { onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, onCloseSidebar: () => void, onWideConversationSource: (source: ConversationSource) => void, + onCompactLayoutEntered: () => void, onRemoteHome: RemoteHomePresentationActions, onRemoteCreate: RemoteCreatePresentationActions, onSidebar: SidebarPresentationActions, onSettings: SettingsPresentationActions, onConnect: ConnectPresentationActions, + onFilePreview: FilePreviewPresentationActions, generalStatus: () => string ) { this.onNavigationBack = onNavigationBack; this.onConversationIntent = onConversationIntent; this.onCloseSidebar = onCloseSidebar; this.onWideConversationSource = onWideConversationSource; + this.onCompactLayoutEntered = onCompactLayoutEntered; this.onRemoteHome = onRemoteHome; this.onRemoteCreate = onRemoteCreate; this.onSidebar = onSidebar; this.onSettings = onSettings; this.onConnect = onConnect; + this.onFilePreview = onFilePreview; this.generalStatus = generalStatus; } } +export class FilePreviewPresentationActions { + readonly close: () => void; + readonly refresh: () => void; + readonly download: (path: string) => void; + readonly openLink: (reference: string, label: string) => void; + + constructor( + close: () => void, + refresh: () => void, + download: (path: string) => void, + openLink: (reference: string, label: string) => void + ) { + this.close = close; + this.refresh = refresh; + this.download = download; + this.openLink = openLink; + } +} + export class RemoteCreatePresentationActions { readonly back: () => void; readonly toggleDevices: () => void; @@ -109,8 +157,12 @@ export class RemoteHomePresentationActions { readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; - readonly create: (agentType: string) => void; readonly createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; readonly openSession: (session: RemoteSession) => void; + readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; + readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; + readonly openSession: (session: RemoteSession) => void; + readonly openSessionInPlace: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; constructor( @@ -119,8 +171,11 @@ export class RemoteHomePresentationActions { selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, - clearPairing: () => void, create: (agentType: string) => void, createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, + clearPairing: () => void, create: (agentType: string) => void, createInPlace: (agentType: string) => void, + createAssistant: () => void, + createInWorkspace: (path: string, agentType: string) => void, + createInWorkspaceInPlace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, + openSessionInPlace: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void ) { this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; @@ -128,8 +183,10 @@ export class RemoteHomePresentationActions { this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; - this.clearPairing = clearPairing; this.create = create; this.createAssistant = createAssistant; - this.createInWorkspace = createInWorkspace; this.openSession = openSession; this.deleteSession = deleteSession; + this.clearPairing = clearPairing; this.create = create; this.createInPlace = createInPlace; + this.createAssistant = createAssistant; this.createInWorkspace = createInWorkspace; + this.createInWorkspaceInPlace = createInWorkspaceInPlace; this.openSession = openSession; + this.openSessionInPlace = openSessionInPlace; this.deleteSession = deleteSession; } } @@ -210,16 +267,32 @@ export struct AppRootPresentation { @Param remotePageState: RemotePageState = new RemotePageState(); @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param deviceId: string = ''; @Param currentRoute: AppRoute = AppRoute.ChatHome; @Local viewportWidth: number = 0; @Local wideLayoutMatched: boolean = false; @Local foldStatus: display.FoldStatus = safeFoldStatus(); + @Local largeScreenLayout: boolean = false; @Local wideMasterPaneWidth: number = ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH; @Local wideMasterDetailGap: number = 0; @Local wideDetailContentOffset: number = 0; @Local wideDetailContentWidth: number = 0; + @Local wideCollapsedDetailContentOffset: number = 0; + @Local wideCollapsedDetailContentWidth: number = 0; + @Local wideMasterPaneCollapsed: boolean = false; + @Local wideMasterPaneMotionActive: boolean = false; + @Local restoreCollapsedMasterAfterPreview: boolean = false; @Local remoteWideSortMode: string = 'project'; + @Local remoteWorkspaceFilter: string = ''; + @Local remoteAgentFilter: string = ''; + @Local remoteStatusFilter: string = ''; + @Local showRemoteViewSettings: boolean = false; + @Local showRemoteWorkspaceMetadata: boolean = false; + @Local showRemoteUpdatedMetadata: boolean = false; + @Local showRemoteStatusMetadata: boolean = false; + private readonly deviceType: string = safeDeviceType(); + private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; private foldStatusChanged: (status: display.FoldStatus) => void = (status: display.FoldStatus): void => { @@ -229,17 +302,19 @@ export struct AppRootPresentation { private wideQueryChanged: (result: mediaQuery.MediaQueryResult) => void = (result: mediaQuery.MediaQueryResult): void => { this.wideLayoutMatched = result.matches; + this.refreshWideGeometry(); }; @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, + () => false, () => {}, () => {}, () => {}, () => {}, new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}), + () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), + new FilePreviewPresentationActions(() => {}, () => {}, () => {}, () => {}), () => '' ); @@ -253,12 +328,27 @@ export struct AppRootPresentation { this.unbindResponsiveQueries(); } + @Monitor('filePreviewState.visible') + onFilePreviewVisibilityChanged(): void { + if (this.filePreviewState.visible && this.wideMasterPaneCollapsed) { + this.restoreCollapsedMasterAfterPreview = true; + this.wideMasterPaneCollapsed = false; + } else if (!this.filePreviewState.visible && this.restoreCollapsedMasterAfterPreview && this.isWideLayout()) { + this.wideMasterPaneCollapsed = true; + this.restoreCollapsedMasterAfterPreview = false; + } + } + build() { Stack() { - AppShell({ shellState: this.shellState, content: () => { this.NavigationContent(); }, + AppShell({ shellState: this.shellState, useWideLayout: this.isWideLayout(), content: () => { this.NavigationContent(); }, sidebar: () => { this.SidebarContent(); }, settings: () => { this.SettingsContent(); }, connect: () => { this.ConnectContent(); }, onCloseSidebar: this.actions.onCloseSidebar }) + if (this.filePreviewPlacement() === FilePreviewPlacement.CompactFullPage) { + this.FilePreviewPane() + } }.width('100%').height('100%') + .bindSheet($$this.showRemoteViewSettings, this.RemoteViewSettingsSheet(), this.remoteViewSettingsSheetOptions()) .onAreaChange((_oldArea: Area, newArea: Area) => { this.viewportWidth = this.areaWidth(newArea.width); this.refreshWideGeometry(); @@ -282,21 +372,39 @@ export struct AppRootPresentation { RouteContent(route: AppRoute) { if (this.isGeneralWideRoute(route) && this.isWideLayout()) { this.WideGeneralChatContent(route) - } else if (route === AppRoute.RemoteChat && this.isWideLayout()) { + } else if (this.showsWideRemoteConversation(route) && + this.filePreviewPlacement() === FilePreviewPlacement.WideFocusSplit) { + this.WideRemotePreviewFocusContent() + } else if (this.showsWideRemoteConversation(route)) { this.WideRemoteChatContent() } else if (route === AppRoute.RemoteHome && this.isWideLayout()) { this.WideRemoteHomeContent() + } else if (route === AppRoute.RemoteCreate && this.isWideLayout()) { + this.WideRemoteCreateContent() } else { - this.RouteSurfaceContent(route, true, true) + this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) } } @Builder - RouteSurfaceContent(route: AppRoute, showSidebarButton: boolean, showBackButton: boolean) { + RouteSurfaceContent( + route: AppRoute, + showSidebarButton: boolean, + showBackButton: boolean, + showSidebarRestoreButton: boolean = false, + useWidePresentation: boolean = false + ) { Column() { if (route === AppRoute.RemoteHome) { RemoteHomeView({ pageState: this.remotePageState, isBusy: this.remotePageState.isBusy, selectedSessionId: this.remotePageState.activeSession.sessionId, + sortMode: this.remoteWideSortMode, + workspaceFilter: this.remoteWorkspaceFilter, + agentFilter: this.remoteAgentFilter, + statusFilter: this.remoteStatusFilter, + showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, + showUpdatedMetadata: this.showRemoteUpdatedMetadata, + showStatusMetadata: this.showRemoteStatusMetadata, onOpenSidebar: this.actions.onRemoteHome.openSidebar, onConnectWorkspace: this.actions.onRemoteHome.connectWorkspace, onAddConnection: this.actions.onRemoteHome.addConnection, @@ -318,10 +426,36 @@ export struct AppRootPresentation { onCreateAssistantSession: this.actions.onRemoteHome.createAssistant, onCreateInWorkspace: this.actions.onRemoteHome.createInWorkspace, onOpenSession: this.actions.onRemoteHome.openSession, - onDeleteSession: this.actions.onRemoteHome.deleteSession }) + onDeleteSession: this.actions.onRemoteHome.deleteSession, + onSortModeChange: (mode: string) => { + this.remoteWideSortMode = mode; + }, + onWorkspaceFilterChange: (value: string) => { + this.remoteWorkspaceFilter = value; + }, + onAgentFilterChange: (value: string) => { + this.remoteAgentFilter = value; + }, + onStatusFilterChange: (value: string) => { + this.remoteStatusFilter = value; + }, + onWorkspaceMetadataChange: (value: boolean) => { + this.showRemoteWorkspaceMetadata = value; + }, + onUpdatedMetadataChange: (value: boolean) => { + this.showRemoteUpdatedMetadata = value; + }, + onStatusMetadataChange: (value: boolean) => { + this.showRemoteStatusMetadata = value; + } }) } else if (route === AppRoute.RemoteCreate) { RemoteCreateSessionView({ state: this.remoteCreateState, + presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + showSidebarRestoreButton: showSidebarRestoreButton, + onRestoreSidebar: () => { + this.restoreWideMasterPane(); + }, onBack: this.actions.onRemoteCreate.back, onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, @@ -334,8 +468,18 @@ export struct AppRootPresentation { ConversationViewHost({ viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, this.actions.generalStatus()), + activeFilePreviewPath: route === AppRoute.RemoteChat && this.filePreviewState.visible ? + this.filePreviewState.target.remotePath : '', + activeFilePreviewLoading: route === AppRoute.RemoteChat && this.filePreviewState.visible && + this.filePreviewState.phase === FilePreviewPhase.Loading, showSidebarButton: showSidebarButton, showBackButton: showBackButton, + showSidebarRestoreButton: showSidebarRestoreButton, + composerPresentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + contentHorizontalOffset: useWidePresentation ? this.collapsedDetailVisualBias() : 0, + onRestoreSidebar: () => { + this.restoreWideMasterPane(); + }, onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) }) } @@ -345,30 +489,49 @@ export struct AppRootPresentation { @Builder WideGeneralChatContent(route: AppRoute) { Row() { - Column() { - AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: 'chat', - showConversationSourceSwitcher: true, - conversationSource: ConversationSource.General, - onClose: this.actions.onSidebar.close, - onNewChat: this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession }) - } - .width(this.wideMasterPaneWidth) - .height('100%') - .backgroundColor(PAGE_BG) - .border({ width: { right: 1 }, color: LINE }) + if (!this.wideMasterPaneCollapsed) { + Column() { + Column() { + AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.generalPageState.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + conversationSource: ConversationSource.General, + onClose: this.actions.onSidebar.close, + onNewChat: this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: () => { + this.collapseWideMasterPane(); + }, + onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession }) + } + .width('100%') + .height('100%') + .backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18) + .clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(this.wideMasterPaneWidth) + .height('100%') + .padding({ left: 10, right: 6, top: 10, bottom: 10 }) + .backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : + TransitionEffect.opacity(1)) - this.WideMasterDetailGap() - this.WideConversationDetail(route, true) + this.WideMasterDetailGap() + } + this.WideConversationDetail(route, false) } .width('100%') .height('100%') @@ -378,8 +541,10 @@ export struct AppRootPresentation { @Builder WideRemoteHomeContent() { Row() { - this.RemoteMasterPane('') - this.WideMasterDetailGap() + if (!this.wideMasterPaneCollapsed) { + this.RemoteMasterPane(false) + this.WideMasterDetailGap() + } Column() { this.RemoteFlowPlaceholder() @@ -394,39 +559,67 @@ export struct AppRootPresentation { } @Builder - RemoteMasterPane(selectedSessionId: string) { - Column({ space: 12 }) { - this.RemoteWidePaneHeader() - ConversationSourceSwitcher({ - activeSource: ConversationSource.Remote, - onSelectSource: this.actions.onWideConversationSource - }) - if (this.canShowRemoteWideList()) { - RemoteSessionList({ + WideRemoteCreateContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.RemoteMasterPane(false) + this.WideMasterDetailGap() + } + this.WideConversationDetail(AppRoute.RemoteCreate, false) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + RemoteMasterPane( + showSelectedSession: boolean, + paneWidth: number = this.wideMasterPaneWidth + ) { + Column() { + Column({ space: 12 }) { + this.RemoteWidePaneHeader() + ConversationSourceSwitcher({ + activeSource: ConversationSource.Remote, + onSelectSource: this.actions.onWideConversationSource + }) + if (this.isRemoteWideInitialLoading()) { + RemoteSessionLoadingView() + this.RemoteWideSearchBar() + } else if (this.canShowRemoteWideList()) { + RemoteSessionList({ sessions: this.remotePageState.visibleSessions(), query: this.remotePageState.sessionQuery, sortMode: this.remoteWideSortMode, + workspaceFilter: this.remoteWorkspaceFilter, + agentFilter: this.remoteAgentFilter, + statusFilter: this.remoteStatusFilter, workspaceName: this.remotePageState.workspaceName, workspacePath: this.remotePageState.workspacePath, workspaceKind: this.remotePageState.workspaceKind, recentWorkspaces: this.remotePageState.recentWorkspaces, + actionPresentation: SessionActionPresentation.Popover, + showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, + showUpdatedMetadata: this.showRemoteUpdatedMetadata, + showStatusMetadata: this.showRemoteStatusMetadata, hasMoreSessions: this.remotePageState.hasMoreSessions, isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: selectedSessionId, + selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', onCreate: () => { - this.actions.onRemoteHome.create('code'); + this.actions.onRemoteHome.createInPlace('code'); }, onCreateAssistantSession: () => { this.actions.onRemoteHome.createAssistant(); }, onCreateInWorkspace: (path: string, agentType: string) => { - this.actions.onRemoteHome.createInWorkspace(path, agentType); + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); }, onSelectWorkspace: (path: string) => { this.actions.onRemoteHome.selectWorkspace(path); }, onOpenSession: (session: RemoteSession) => { - this.actions.onRemoteHome.openSession(session); + this.actions.onRemoteHome.openSessionInPlace(session); }, onDeleteSession: (session: RemoteSession) => { this.actions.onRemoteHome.deleteSession(session); @@ -434,17 +627,29 @@ export struct AppRootPresentation { onLoadMore: () => { this.actions.onRemoteHome.loadMore(); } - }) - this.RemoteWideSearchBar() - } else { - this.RemoteWideDisconnected() + }) + this.RemoteWideSearchBar() + } else { + this.RemoteWideDisconnected() + } } + .width('100%') + .height('100%') + .padding({ left: 20, right: 16, top: 10, bottom: 8 }) + .backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18) + .clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) } - .width(this.wideMasterPaneWidth) + .width(paneWidth) .height('100%') - .padding({ left: 24, right: 18, top: 14, bottom: 12 }) + .padding({ left: 10, right: 6, top: 10, bottom: 10 }) .backgroundColor(PAGE_BG) - .border({ width: { right: 1 }, color: LINE }) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : + TransitionEffect.opacity(1)) } @Builder @@ -458,7 +663,7 @@ export struct AppRootPresentation { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row({ space: 6 }) { - this.RemoteWideStatusDot() + this.RemoteWideStatusIndicator() Text(this.remoteWideStatusText()) .fontSize(12) .fontColor(MUTED) @@ -472,20 +677,33 @@ export struct AppRootPresentation { .layoutWeight(1) .alignItems(HorizontalAlign.Start) - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(22) + Row({ space: 6 }) { + Stack({ alignContent: Alignment.Center }) { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } .height(8) - .objectFit(ImageFit.Contain) + .alignItems(VerticalAlign.Center) + } + .width(38) + .height(38) + .backgroundColor(CARD) + .borderRadius(19) + .border({ width: 1, color: LINE }) + .accessibilityText(RemoteI18n.t('remote.actions')) + .onClick(() => { + this.showRemoteViewSettings = true; + }) + + SidebarToggleButton({ + controlSize: 38, + onToggle: () => { + this.collapseWideMasterPane(); + } + }) } - .width(38) - .height(38) - .backgroundColor(CARD) - .borderRadius(19) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.actions.onRemoteHome.openSettings(); - }) } .width('100%') .height(46) @@ -493,24 +711,75 @@ export struct AppRootPresentation { } @Builder - RemoteWideStatusDot() { - Stack() { - Text('') + RemoteViewSettingsSheet() { + ConversationViewSettings({ + sessions: this.remotePageState.visibleSessions(), + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + sortMode: this.remoteWideSortMode, + workspaceFilter: this.remoteWorkspaceFilter, + agentFilter: this.remoteAgentFilter, + statusFilter: this.remoteStatusFilter, + showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, + showUpdatedMetadata: this.showRemoteUpdatedMetadata, + showStatusMetadata: this.showRemoteStatusMetadata, + onSortModeChange: (mode: string) => { + this.remoteWideSortMode = mode; + }, + onWorkspaceFilterChange: (value: string) => { + RemoteLogger.info(`wide view-settings workspace received=${value.length > 0 ? value : ''}`); + this.remoteWorkspaceFilter = value; + }, + onAgentFilterChange: (value: string) => { + this.remoteAgentFilter = value; + }, + onStatusFilterChange: (value: string) => { + this.remoteStatusFilter = value; + }, + onWorkspaceMetadataChange: (value: boolean) => { + this.showRemoteWorkspaceMetadata = value; + }, + onUpdatedMetadataChange: (value: boolean) => { + this.showRemoteUpdatedMetadata = value; + }, + onStatusMetadataChange: (value: boolean) => { + this.showRemoteStatusMetadata = value; + }, + onClose: () => { + this.showRemoteViewSettings = false; + } + }) + } + + @Builder + RemoteWideStatusIndicator() { + if (this.isRemoteWideInitialLoading()) { + LoadingProgress() + .width(14) + .height(14) + .color(MUTED) + } else { + Stack() { + Text('') + } + .width(7) + .height(7) + .backgroundColor(this.remoteWideStatusColor()) + .borderRadius(4) } - .width(7) - .height(7) - .backgroundColor(this.remoteWideStatusColor()) - .borderRadius(4) } @Builder RemoteWideSearchBar() { Row({ space: 8 }) { Row({ space: 7 }) { - Image($r('app.media.remote_ref_search_reference')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(16) + .fontColor([MUTED]) + .width(18) + .height(18) .opacity(0.58) TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.remotePageState.sessionQuery }) .height(38) @@ -535,18 +804,19 @@ export struct AppRootPresentation { .border({ width: 1, color: LINE }) Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_new_chat')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(20) + .fontColor([PRIMARY_ACTION_TEXT]) + .width(22) + .height(22) } .width(42) .height(42) - .backgroundColor(INK) + .backgroundColor(PRIMARY_ACTION) .borderRadius(21) - .opacity(this.remotePageState.isBusy ? 0.45 : 1) + .opacity(this.isRemoteWideActionBusy() ? 0.45 : 1) .onClick(() => { - if (!this.remotePageState.isBusy) { + if (!this.isRemoteWideActionBusy()) { this.actions.onRemoteHome.createAssistant(); } }) @@ -560,10 +830,9 @@ export struct AppRootPresentation { RemoteWideDisconnected() { Column({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_device')) - .width(42) - .height(42) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(42) + .fontColor([INK]) } .width(74) .height(74) @@ -580,12 +849,13 @@ export struct AppRootPresentation { .lineHeight(20) .fontColor(MUTED) .textAlign(TextAlign.Center) - Button(RemoteI18n.t('connect.connect')) + Text(RemoteI18n.t('connect.connect')) .width(136) .height(44) .fontSize(15) - .fontColor(CARD) - .backgroundColor(INK) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) .borderRadius(22) .onClick(() => { this.actions.onRemoteHome.connectWorkspace(); @@ -600,10 +870,48 @@ export struct AppRootPresentation { @Builder WideRemoteChatContent() { + if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { + Row() { + this.RemoteMasterPane( + true, + this.filePreviewLayout().masterPaneWidth + ) + this.WidePaneGap(this.filePreviewLayout().masterConversationGap) + this.WideConversationDetail( + AppRoute.RemoteChat, + false, + this.filePreviewLayout().conversationPaneWidth + ) + this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } else { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.RemoteMasterPane(true) + this.WideMasterDetailGap() + } + this.WideConversationDetail(AppRoute.RemoteChat, false) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + } + + @Builder + WideRemotePreviewFocusContent() { Row() { - this.RemoteMasterPane(this.remotePageState.activeSession.sessionId) - this.WideMasterDetailGap() - this.WideConversationDetail(AppRoute.RemoteChat, false) + this.WideConversationDetail( + AppRoute.RemoteChat, + false, + this.filePreviewLayout().conversationPaneWidth + ) + this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) } .width('100%') .height('100%') @@ -611,31 +919,82 @@ export struct AppRootPresentation { } @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean) { - Row() { - if (this.wideDetailContentOffset > 0) { - Blank().width(this.wideDetailContentOffset) + FilePreviewPane(paneWidth: number = 0) { + Column() { + FilePreviewSurface({ + state: this.filePreviewState, + remoteAvailable: RemoteUiState.canUseRemote(this.remotePageState.connectionState), + downloadPath: this.remotePageState.downloadingFilePath, + downloadedPath: this.remotePageState.downloadedFilePath, + downloadStatus: this.remotePageState.fileDownloadStatus, + onClose: this.actions.onFilePreview.close, + onRefresh: this.actions.onFilePreview.refresh, + onDownload: this.actions.onFilePreview.download, + onOpenLink: this.actions.onFilePreview.openLink + }) + } + .layoutWeight(paneWidth > 0 ? 0 : 1) + .width(paneWidth > 0 ? paneWidth : '100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurfaceContent(route, false, showBackButton, false, true) } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton) + .width(paneWidth) + .height('100%') + .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } else { + Stack({ alignContent: Alignment.TopStart }) { + Row() { + if (this.currentDetailContentOffset() > 0) { + Blank().width(this.currentDetailContentOffset()) + } + Row() { + Column() { + this.RouteSurfaceContent(route, false, showBackButton, false, true) + } + .width('100%') + .height('100%') + .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } + .width(this.currentDetailContentWidth() > 0 ? this.currentDetailContentWidth() : '100%') + .height('100%') + .justifyContent(FlexAlign.Center) + if (this.currentDetailContentOffset() > 0) { + Blank().layoutWeight(1) + } } .width('100%') .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .justifyContent(FlexAlign.Center) .backgroundColor(PAGE_BG) + + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ + restore: true, + controlSize: 44, + onToggle: () => { + this.restoreWideMasterPane(); + } + }) + .position({ x: this.currentDetailContentOffset() + 12, y: 12 }) + .zIndex(2) + .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } } - .width(this.wideDetailContentWidth > 0 ? this.wideDetailContentWidth : '100%') + .layoutWeight(1) .height('100%') - .justifyContent(FlexAlign.Center) - if (this.wideDetailContentOffset > 0) { - Blank().layoutWeight(1) - } + .backgroundColor(PAGE_BG) } - .layoutWeight(1) - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) } @Builder @@ -649,10 +1008,32 @@ export struct AppRootPresentation { } } + @Builder + WidePaneGap(width: number) { + if (width > 0) { + Row() { + } + .width(width) + .height('100%') + .backgroundColor(LINE) + } + } + @Builder RemoteFlowPlaceholder() { Column() { - Row() { + Row({ space: 8 }) { + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: () => { + this.restoreWideMasterPane(); + } + }) + } else { + Blank().width(48).height(48) + } Column({ space: 4 }) { Text(RemoteI18n.t('remote.chats')) .fontSize(20) @@ -664,15 +1045,23 @@ export struct AppRootPresentation { .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } - .alignItems(HorizontalAlign.Start) - Blank() + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) } .width('100%') .height(76) - .padding({ left: 24, right: 24, top: 14, bottom: 12 }) + .padding({ left: 16, right: 16, top: 14, bottom: 12 }) .border({ width: { bottom: 1 }, color: LINE }) Column({ space: 8 }) { + if (this.isRemoteWideInitialLoading()) { + LoadingProgress() + .width(28) + .height(28) + .color(MUTED) + .margin({ bottom: 8 }) + } Text(this.remoteFlowPlaceholderTitle()) .fontSize(22) .fontWeight(FontWeight.Bold) @@ -695,10 +1084,63 @@ export struct AppRootPresentation { } private isWideLayout(): boolean { - return ConversationLayoutPolicy.useMasterDetail( + return this.largeScreenLayout; + } + + private collapseWideMasterPane(): void { + if (!this.isWideLayout() || this.filePreviewState.visible) { + return; + } + this.enableWideMasterPaneMotion(); + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { + this.wideMasterPaneCollapsed = true; + }); + } + + private restoreWideMasterPane(): void { + this.enableWideMasterPaneMotion(); + this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseInOut }, () => { + this.wideMasterPaneCollapsed = false; + this.restoreCollapsedMasterAfterPreview = false; + }); + } + + private enableWideMasterPaneMotion(): void { + this.wideMasterPaneMotionActive = true; + setTimeout(() => { + this.wideMasterPaneMotionActive = false; + }, 240); + } + + private currentDetailContentOffset(): number { + return this.wideMasterPaneCollapsed ? + this.wideCollapsedDetailContentOffset : this.wideDetailContentOffset; + } + + private currentDetailContentWidth(): number { + return this.wideMasterPaneCollapsed ? + this.wideCollapsedDetailContentWidth : this.wideDetailContentWidth; + } + + private collapsedDetailVisualBias(): number { + if (!this.wideMasterPaneCollapsed || this.wideCollapsedDetailContentOffset > 0) { + return 0; + } + const availableMargin = (this.wideCollapsedDetailContentWidth - WIDE_DETAIL_CONTENT_MAX_WIDTH) / 2; + return Math.min(72, Math.max(0, availableMargin)); + } + + private filePreviewPlacement(): FilePreviewPlacement { + return this.filePreviewLayout().placement; + } + + private filePreviewLayout(): FilePreviewLayout { + return FilePreviewPlacementPolicy.resolveLayout( + this.filePreviewState.visible, + this.isWideLayout(), this.viewportWidth, - this.wideLayoutMatched, - this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED + this.verticalCreases, + this.wideMasterPaneWidth ); } @@ -706,6 +1148,14 @@ export struct AppRootPresentation { return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; } + private showsWideRemoteConversation(route: AppRoute): boolean { + if (!this.isWideLayout()) { + return false; + } + return route === AppRoute.RemoteChat || + (route === AppRoute.RemoteHome && this.remotePageState.activeSession.sessionId.length > 0); + } + private bindResponsiveQueries(): void { this.unbindResponsiveQueries(); try { @@ -753,14 +1203,29 @@ export struct AppRootPresentation { } private refreshWideGeometry(): void { + const wasWideLayout = this.largeScreenLayout; + const verticalCreases = this.currentVerticalCreases(); + this.verticalCreases = verticalCreases; + this.largeScreenLayout = ConversationLayoutPolicy.useMasterDetail( + this.viewportWidth, + this.wideLayoutMatched, + this.foldStatus === display.FoldStatus.FOLD_STATUS_FOLDED, + this.deviceType, + verticalCreases + ); const geometry = ConversationLayoutPolicy.resolveWideGeometry( this.viewportWidth, - this.currentVerticalCreases() + verticalCreases ); this.wideMasterPaneWidth = geometry.masterPaneWidth; this.wideMasterDetailGap = geometry.masterDetailGap; this.wideDetailContentOffset = geometry.detailContentOffset; this.wideDetailContentWidth = geometry.detailContentWidth; + this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; + this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + if (wasWideLayout && !this.largeScreenLayout) { + this.actions.onCompactLayoutEntered(); + } } private currentVerticalCreases(): ConversationLayoutCrease[] { @@ -787,6 +1252,20 @@ export struct AppRootPresentation { this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; } + private isRemoteWideInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isRemoteWideConnecting(); + } + + private isRemoteWideConnecting(): boolean { + return this.remotePageState.connectionState === 'parsing' || + this.remotePageState.connectionState === 'pairing' || + this.remotePageState.connectionState === 'reconnecting'; + } + + private isRemoteWideActionBusy(): boolean { + return this.remotePageState.isBusy || this.isRemoteWideInitialLoading(); + } + private remoteWideStatusText(): string { if (this.remotePageState.statusText.length > 0) { return this.remotePageState.statusText; @@ -794,7 +1273,7 @@ export struct AppRootPresentation { return this.remoteDesktopName(); } - private remoteWideStatusColor(): string { + private remoteWideStatusColor(): ResourceColor { if (this.remotePageState.connectionState === 'connected') { return GREEN; } @@ -810,9 +1289,24 @@ export struct AppRootPresentation { } private remoteFlowPlaceholderTitle(): string { + if (this.isRemoteWideInitialLoading()) { + return RemoteI18n.t('common.loading'); + } return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); } + private remoteViewSettingsSheetOptions(): SheetOptions { + return { + height: 520, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + @Builder SidebarContent() { AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), selectedSessionId: this.currentRoute === AppRoute.ChatHome || this.currentRoute === AppRoute.GeneralChat ? @@ -849,6 +1343,8 @@ export struct AppRootPresentation { } else { SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, hasGeneralChatApiKey: this.generalPageState.hasApiKey, + generalChatModelCatalog: this.generalPageState.modelCatalog, + selectedGeneralChatModelId: this.generalPageState.selectedModelId, accountUsername: this.remotePageState.accountUsername, authenticatedUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index 1d210fd783..c5651b0255 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -1,14 +1,25 @@ import { AppShellState } from '../state/AppShellState'; +import { CARD, PAGE_BG } from './Theme'; + +const WIDE_SETTINGS_SHEET_MAX_WIDTH: number = 680; +const WIDE_CONNECT_SHEET_MAX_WIDTH: number = 620; +const WIDE_SHEET_MIN_WIDTH: number = 540; +const WIDE_SHEET_MAX_HEIGHT: number = 760; +const WIDE_SHEET_MIN_HEIGHT: number = 560; +const WIDE_SHEET_VERTICAL_MARGIN: number = 80; +const WIDE_SHEET_RADIUS: number = 30; @ComponentV2 export struct AppShell { @Param shellState: AppShellState = new AppShellState(); + @Param useWideLayout: boolean = false; @BuilderParam content: () => void = this.EmptyBuilder; @BuilderParam sidebar: () => void = this.EmptyBuilder; @BuilderParam settings: () => void = this.EmptyBuilder; @BuilderParam connect: () => void = this.EmptyBuilder; @Event onCloseSidebar: () => void = () => {}; @Local shellWidth: number = 440; + @Local shellHeight: number = 0; build() { Stack({ alignContent: Alignment.Start }) { @@ -31,20 +42,15 @@ export struct AppShell { } .width('100%') .height('100%') - .bindSheet($$this.shellState.showConnectSheet, this.connect(), { - height: SheetSize.LARGE, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: false - }) + .bindSheet($$this.shellState.showConnectSheet, this.ConnectSheetContent(), this.connectSheetOptions()) if (this.shellState.showSidebar) { Column() { } .width('100%') .height('100%') - .backgroundColor('#66FFFFFF') + .backgroundColor(CARD) + .opacity(0.62) .transition(TransitionEffect.opacity(0) .animation({ duration: 210, curve: Curve.EaseOut })) .onClick(() => { @@ -54,7 +60,7 @@ export struct AppShell { } .width('100%') .height('100%') - .backgroundColor('#FFFFFF') + .backgroundColor(PAGE_BG) .borderRadius(this.shellState.showSidebar ? 28 : 0) .clip(true) .shadow({ @@ -74,30 +80,45 @@ export struct AppShell { duration: this.shellState.showSidebar ? 320 : 250, curve: Curve.EaseOut }) - - if (this.shellState.showAccount) { - Column() { - this.settings() - } - .width('100%') - .height('100%') - .backgroundColor('#FFFFFF') - .zIndex(20) - .transition(TransitionEffect.opacity(0) - .animation({ duration: 180, curve: Curve.EaseOut })) - } } .width('100%') .height('100%') .onAreaChange((_oldArea: Area, newArea: Area) => { - this.shellWidth = Number(newArea.width); + this.shellWidth = this.areaLength(newArea.width); + this.shellHeight = this.areaLength(newArea.height); }) - .bindSheet($$this.shellState.showSettings, this.settings(), { - height: SheetSize.LARGE, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: false + .bindSheet($$this.shellState.showSettings, this.SettingsSheetContent(), this.settingsSheetOptions()) + } + + @Builder + private SettingsSheetContent() { + Column() { + this.settings() + } + .width('100%') + .height('100%') + .borderRadius(this.shouldUseWideSheetLayout() ? WIDE_SHEET_RADIUS : 0) + .clip(this.shouldUseWideSheetLayout()) + .shadow({ + radius: this.shouldUseWideSheetLayout() ? 30 : 0, + color: this.shouldUseWideSheetLayout() ? '#22000000' : '#00000000', + offsetY: this.shouldUseWideSheetLayout() ? 12 : 0 + }) + } + + @Builder + private ConnectSheetContent() { + Column() { + this.connect() + } + .width('100%') + .height('100%') + .borderRadius(this.shouldUseWideSheetLayout() ? WIDE_SHEET_RADIUS : 0) + .clip(this.shouldUseWideSheetLayout()) + .shadow({ + radius: this.shouldUseWideSheetLayout() ? 30 : 0, + color: this.shouldUseWideSheetLayout() ? '#22000000' : '#00000000', + offsetY: this.shouldUseWideSheetLayout() ? 12 : 0 }) } @@ -111,6 +132,75 @@ export struct AppShell { return Math.min(420, Math.max(280, Math.round(this.shellWidth * 0.35))); } + private settingsSheetOptions(): SheetOptions { + if (!this.shouldUseWideSheetLayout()) { + return this.bottomSheetOptions(); + } + return this.wideCenterSheetOptions(this.settingsSheetWidth()); + } + + private connectSheetOptions(): SheetOptions { + if (!this.shouldUseWideSheetLayout()) { + return this.bottomSheetOptions(); + } + return this.wideCenterSheetOptions(this.connectSheetWidth()); + } + + private bottomSheetOptions(): SheetOptions { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private wideCenterSheetOptions(width: number): SheetOptions { + return { + height: this.wideSheetHeight(), + width, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private settingsSheetWidth(): number { + return Math.min( + WIDE_SETTINGS_SHEET_MAX_WIDTH, + Math.max(WIDE_SHEET_MIN_WIDTH, Math.round(this.shellWidth * 0.48)) + ); + } + + private connectSheetWidth(): number { + return Math.min( + WIDE_CONNECT_SHEET_MAX_WIDTH, + Math.max(WIDE_SHEET_MIN_WIDTH, Math.round(this.shellWidth * 0.46)) + ); + } + + private wideSheetHeight(): number { + if (this.shellHeight <= 0) { + return WIDE_SHEET_MAX_HEIGHT; + } + return Math.min( + WIDE_SHEET_MAX_HEIGHT, + Math.max(WIDE_SHEET_MIN_HEIGHT, Math.round(this.shellHeight - WIDE_SHEET_VERTICAL_MARGIN)) + ); + } + + private shouldUseWideSheetLayout(): boolean { + return this.useWideLayout; + } + + private areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } + @Builder EmptyBuilder() { Column() { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 3576fbfe77..c1bf484f3d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -1,8 +1,12 @@ import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, MUTED, SUBTLE } from './Theme'; +import { CARD, FLOATING_PANEL_BG, INK, LINE, MUTED, SOFT, SUBTLE } from './Theme'; import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionDetailsView } from './SessionDetailsView'; @Component export struct AppSidebar { @@ -13,11 +17,13 @@ export struct AppSidebar { @Prop activeSection: string = 'chat'; @Prop accountUserId: string = ''; @Prop showConversationSourceSwitcher: boolean = false; + @Prop showCollapseButton: boolean = false; @Prop conversationSource: ConversationSource = ConversationSource.General; onClose: () => void = () => {}; onNewChat: () => void = () => {}; onEnterCode: () => void = () => {}; onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + onCollapse: () => void = () => {}; onOpenSettings: () => void = () => {}; onOpenAccount: () => void = () => {}; onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -26,7 +32,9 @@ export struct AppSidebar { onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @State activeActionSessionId: string = ''; - @State pendingDeleteSessionId: string = ''; + @State showSessionActionSheet: boolean = false; + @State detailsSessionId: string = ''; + @State showSessionDetails: boolean = false; @State showSearch: boolean = false; @State sessionSearchQuery: string = ''; @State archivedSessionsExpanded: boolean = false; @@ -61,19 +69,19 @@ export struct AppSidebar { if (this.visiblePinnedSessions().length > 0) { Text('置顶') - .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) - .width('100%').margin({ top: 18, bottom: 10 }) + .fontSize(14).fontWeight(FontWeight.Medium).fontColor(MUTED) + .width('100%').margin({ top: 16, bottom: 6 }) ForEach(this.visiblePinnedSessions(), (session: RemoteSession) => { this.PinnedRow(session) }, (session: RemoteSession) => session.id) } Text(RemoteI18n.t('sidebar.recent')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) .width('100%') - .margin({ top: 18, bottom: 10 }) + .margin({ top: 16, bottom: 6 }) Stack({ alignContent: Alignment.Bottom }) { List({ space: 2 }) { @@ -109,7 +117,7 @@ export struct AppSidebar { .width('100%') .height('100%') .margin({ left: 0 }) - .padding({ bottom: 72 }) + .padding({ bottom: 84 }) .scrollBar(BarState.Off) .divider(null) @@ -124,32 +132,45 @@ export struct AppSidebar { } .width('100%') .height('100%') - .padding({ left: 24, right: 18, top: 4, bottom: 7 }) - .backgroundColor(CARD) + .padding({ left: 20, right: 20, top: 4, bottom: 16 }) + .backgroundColor(FLOATING_PANEL_BG) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) + .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @Builder private AuthenticatedHeader() { Row() { Text('BitFun') - .fontSize(23) + .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() - Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() - } - .width(46) - .height(46) + Row({ space: 6 }) { + Stack({ alignContent: Alignment.Center }) { + this.SearchGlyph() + } + .width(38) + .height(38) .backgroundColor(CARD) - .borderRadius(23) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) + .border({ width: 1, color: LINE }) + .borderRadius(19) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('common.search')) .onClick(() => { this.showSearch = !this.showSearch; if (!this.showSearch) { this.sessionSearchQuery = ''; } }) + + if (this.showCollapseButton) { + SidebarToggleButton({ + controlSize: 38, + onToggle: this.onCollapse + }) + } + } } .width('100%') .height(50) @@ -163,7 +184,7 @@ export struct AppSidebar { .fontColor(INK) .placeholderColor(SUBTLE) .padding({ left: 14, right: 14 }) - .backgroundColor('#F4F4F2') + .backgroundColor(SOFT) .borderRadius(8) .margin({ top: 12 }) .onChange((value: string) => { @@ -174,43 +195,55 @@ export struct AppSidebar { @Builder private SignedOutHeader() { - Row({ space: 16 }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(25) - .fontColor([INK]) - .width(26) - .height(26) - Text(RemoteI18n.t('sidebar.signedOutNewChat')) - .fontSize(18) - .fontWeight(FontWeight.Medium) - .fontColor(INK) + Row() { + Row({ space: 14 }) { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(23) + .fontColor([INK]) + .width(24) + .height(24) + Text(RemoteI18n.t('sidebar.signedOutNewChat')) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + } + .layoutWeight(1) + .height(50) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.onNewChat(); + }) + + if (this.showCollapseButton) { + SidebarToggleButton({ + controlSize: 38, + onToggle: this.onCollapse + }) + } } .width('100%') .height(50) .alignItems(VerticalAlign.Center) - .onClick(() => { - this.onNewChat(); - }) } @Builder private AuthenticatedFooter() { Row() { - Button() { - Row({ space: 10 }) { - this.EditGlyph() - Text(RemoteI18n.t('sidebar.newChat')) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - } - .justifyContent(FlexAlign.Center) - .width('100%') + Row({ space: 9 }) { + this.EditGlyph() + Text(RemoteI18n.t('sidebar.newChat')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) } .width(116) - .height(44) - .backgroundColor('#3B82F6') - .borderRadius(22) + .height(46) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(23) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) .onClick(() => { this.onNewChat(); }) @@ -224,13 +257,15 @@ export struct AppSidebar { .height(46) .backgroundColor(CARD) .borderRadius(23) - .shadow({ radius: 16, color: '#0E000000', offsetY: 8 }) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) .onClick(() => { this.onOpenSettings(); }) } .width('100%') + .height(56) .alignItems(VerticalAlign.Center) + .zIndex(2) } @Builder @@ -261,7 +296,7 @@ export struct AppSidebar { .width('100%') .height(46) .padding({ left: 12, right: 8 }) - .backgroundColor(isActive ? '#F3F3F3' : '#00000000') + .backgroundColor(isActive ? SOFT : '#00000000') .borderRadius(12) .onClick(action) } @@ -275,7 +310,7 @@ export struct AppSidebar { .width(22) .height(22) Text(RemoteI18n.t('sidebar.archived')) - .fontSize(16) + .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(INK) Blank() @@ -287,7 +322,7 @@ export struct AppSidebar { .width(24) .height(22) .textAlign(TextAlign.Center) - .backgroundColor('#EFEFED') + .backgroundColor(SOFT) .borderRadius(11) Stack({ alignContent: Alignment.Center }) { if (this.archivedSessionsExpanded) { @@ -309,125 +344,138 @@ export struct AppSidebar { } .width('100%') .height(46) - .padding({ left: 14, right: 68 }) + .padding({ left: 12, right: 68 }) .margin({ top: 8 }) - .backgroundColor(this.archivedSessionsExpanded ? '#F7F7F5' : '#00000000') - .borderRadius(14) + .backgroundColor(this.archivedSessionsExpanded ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => { this.archivedSessionsExpanded = !this.archivedSessionsExpanded; - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; + this.closeSessionActions(); }) } @Builder PinnedRow(session: RemoteSession) { - Row({ space: 10 }) { - Image($r('app.media.remote_actions_check')) - .width(19).height(19).objectFit(ImageFit.Contain) + Row({ space: 8 }) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(18).fontColor([MUTED]).width(19).height(19) Text(session.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(17).fontColor(INK).layoutWeight(1) + .fontSize(15).fontColor(INK).layoutWeight(1) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + this.SessionMoreButton(session) } - .width('100%').height(46) - .padding({ left: 14, right: 12 }) - .backgroundColor(this.selectedSessionId === session.id ? '#F3F3F3' : '#00000000') - .borderRadius(14) + .width('100%').height(44) + .padding({ left: 12, right: 4 }) + .backgroundColor(this.selectedSessionId === session.id ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => this.openSession(session)) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(session))) + .bindPopup(this.showCollapseButton && this.activeActionSessionId === session.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); + } + } + }) } @Builder RecentRow(obj: RepeatItem) { - Column({ space: 4 }) { - Row() { - Text(obj.item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(17) - .fontWeight(FontWeight.Regular) - .fontColor(INK) - .width('100%') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(46) - .padding({ left: 14, right: 12 }) - .backgroundColor(this.selectedSessionId === obj.item.id || this.activeActionSessionId === obj.item.id ? - '#F3F3F3' : '#00000000') - .borderRadius(14) - .onClick(() => { - this.openSession(obj.item); - }) - .gesture( - LongPressGesture({ repeat: false }) - .onAction(() => { - this.activeActionSessionId = obj.item.id; - this.pendingDeleteSessionId = ''; - }) - ) - if (this.activeActionSessionId === obj.item.id && this.pendingDeleteSessionId !== obj.item.id) { - Column({ space: 2 }) { - if (obj.item.agentType === 'chat') { - this.SessionAction(obj.item.status === 'archived' ? - RemoteI18n.t('sidebar.unarchive') : RemoteI18n.t('sidebar.archive'), () => { - this.activeActionSessionId = ''; - this.onArchiveSession(obj.item, obj.item.status !== 'archived'); - }) - this.SessionAction(RemoteI18n.t('sidebar.exportMarkdown'), () => { - this.activeActionSessionId = ''; - this.onExportSession(obj.item); - }) - } - this.SessionAction(RemoteI18n.t('common.delete'), () => { - this.pendingDeleteSessionId = obj.item.id; - }, true) - this.SessionAction(RemoteI18n.t('common.cancel'), () => { - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; - }) + Row({ space: 8 }) { + Text(obj.item.title || RemoteI18n.t('sidebar.untitled')) + .fontSize(15) + .fontWeight(FontWeight.Regular) + .fontColor(INK) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + this.SessionMoreButton(obj.item) + } + .width('100%') + .height(44) + .padding({ left: 12, right: 4 }) + .backgroundColor(this.selectedSessionId === obj.item.id || this.activeActionSessionId === obj.item.id ? + SOFT : '#00000000') + .borderRadius(10) + .onClick(() => { + this.openSession(obj.item); + }) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(obj.item))) + .bindPopup(this.showCollapseButton && this.activeActionSessionId === obj.item.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); } - .width('100%') - .padding({ top: 6, bottom: 2 }) } - if (this.pendingDeleteSessionId === obj.item.id) { - Row({ space: 10 }) { - Text(RemoteI18n.t('sidebar.deleteConfirm')) - .fontSize(12) - .fontColor(SUBTLE) - .layoutWeight(1) - Text(RemoteI18n.t('common.delete')) - .fontSize(12) - .fontColor('#C33B32') - .height(30) - .padding({ left: 12, right: 12 }) - .textAlign(TextAlign.Center) - .backgroundColor('#FFF0EE') - .borderRadius(15) - .onClick(() => { - this.activeActionSessionId = ''; - this.pendingDeleteSessionId = ''; - this.onDeleteSession(obj.item); - }) - } - .width('100%') - .margin({ top: 6 }) - .alignItems(VerticalAlign.Center) + }) + } + + @Builder + private SessionMoreButton(session: RemoteSession) { + Stack({ alignContent: Alignment.Center }) { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) } + .height(8) + .alignItems(VerticalAlign.Center) } - .width('100%') + .width(34) + .height(40) + .opacity(0.62) + .accessibilityText(RemoteI18n.t('session.actions')) + .onClick(() => this.openSessionActions(session)) } @Builder - SessionAction(label: string, action: () => void, destructive: boolean = false) { - Text(label) - .width('100%') - .height(34) - .padding({ left: 12, right: 12 }) - .fontSize(13) - .fontColor(destructive ? '#C33B32' : INK) - .textAlign(TextAlign.Start) - .backgroundColor(destructive ? '#FFF0EE' : '#F4F4F2') - .borderRadius(6) - .onClick(action) + private SessionActionSheet() { + this.SessionActionContent(SessionActionPresentation.BottomSheet) + } + + @Builder + private SessionActionPopover() { + this.SessionActionContent(SessionActionPresentation.Popover) + } + + @Builder + private SessionActionContent(presentation: SessionActionPresentation) { + SessionActionSurface({ + presentation, + sessionTitle: this.actionSessionTitle(), + archived: this.actionSessionArchived(), + canViewDetails: this.actionCapabilities().canViewDetails, + canArchive: this.actionCapabilities().canArchive, + canExport: this.actionCapabilities().canExport, + canDelete: this.actionCapabilities().canDelete, + onViewDetails: () => this.openActionSessionDetails(), + onArchive: () => this.archiveActionSession(), + onExport: () => this.exportActionSession(), + onDelete: () => this.deleteActionSession(), + onClose: () => this.closeSessionActions() + }) + } + + @Builder + private SessionDetailsSheet() { + SessionDetailsView({ + session: this.detailsSession(), + onClose: () => this.closeSessionDetails() + }) } private openSession(item: RemoteSession): void { @@ -455,26 +503,20 @@ export struct AppSidebar { @Builder RemoteGlyph() { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) - } + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(24) + .fontColor([this.connectionState === 'connected' || this.connectionState === 'reconnecting' ? INK : MUTED]) + .width(35) + .height(34) } @Builder SearchGlyph() { - Image($r('app.media.sidebar_ref_search_reference')) - .width(23) - .height(23) - .objectFit(ImageFit.Contain) - .translate({ x: -2.5, y: 1.5 }) + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } @Builder @@ -622,19 +664,20 @@ export struct AppSidebar { @Builder EditGlyph() { - Image($r('app.media.remote_ref_new_chat')) - .width(25) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } @Builder SettingsGlyph() { - Image($r('app.media.sidebar_ref_settings_reference')) - .width(24.5) - .height(25) - .objectFit(ImageFit.Contain) - .translate({ x: -5 }) + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) } private visibleRecentSessions(): RemoteSession[] { @@ -670,6 +713,118 @@ export struct AppSidebar { }); } + private openSessionActions(session: RemoteSession): void { + this.activeActionSessionId = session.id; + if (!this.showCollapseButton) { + this.showSessionActionSheet = true; + } + } + + private closeSessionActions(): void { + this.showSessionActionSheet = false; + this.activeActionSessionId = ''; + } + + private actionSession(): RemoteSession | undefined { + return this.sessions.find((session: RemoteSession) => session.id === this.activeActionSessionId); + } + + private actionSessionTitle(): string { + const session = this.actionSession(); + return session ? session.title : ''; + } + + private actionSessionArchived(): boolean { + const session = this.actionSession(); + return session ? session.status === 'archived' : false; + } + + private actionSessionIsGeneralChat(): boolean { + const session = this.actionSession(); + return session ? session.agentType === 'chat' : false; + } + + private actionCapabilities(): SessionActionCapabilities { + const session = this.actionSession(); + return SessionActionPolicy.resolve( + SessionActionScope.General, + session ? session.agentType : '', + session === undefined + ); + } + + private archiveActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onArchiveSession(session, session.status !== 'archived'); + } + } + + private exportActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onExportSession(session); + } + } + + private deleteActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onDeleteSession(session); + } + } + + private openActionSessionDetails(): void { + const session = this.actionSession(); + if (session) { + this.detailsSessionId = session.id; + this.showSessionDetails = true; + } + } + + private closeSessionDetails(): void { + this.showSessionDetails = false; + this.detailsSessionId = ''; + } + + private detailsSession(): RemoteSession { + const session = this.sessions.find((item: RemoteSession) => item.id === this.detailsSessionId); + return session || { + id: '', title: '', agentType: '', status: '', updatedAt: '', createdAt: '', messageCount: 0 + }; + } + + private sessionActionSheetOptions(): SheetOptions { + return { + height: this.actionSessionIsGeneralChat() ? 380 : 300, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private sessionDetailsSheetOptions(): SheetOptions { + if (!this.showCollapseButton) { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + return { + height: 560, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 687cdbf92e..38375e4387 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -1,6 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; -import { CARD, INK, MUTED } from './Theme'; +import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; @Component export struct BitFunAccountLoginPage { @@ -36,7 +36,7 @@ export struct BitFunAccountLoginPage { .height(58) .fontSize(17) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(18) .padding({ left: 20, right: 20 }) @@ -46,7 +46,7 @@ export struct BitFunAccountLoginPage { .height(58) .fontSize(17) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(18) .padding({ left: 20, right: 20 }) @@ -64,7 +64,7 @@ export struct BitFunAccountLoginPage { .height(52) .fontSize(14) .fontColor(INK) - .placeholderColor('#B8B8BC') + .placeholderColor(SUBTLE) .backgroundColor(CARD) .borderRadius(16) .padding({ left: 18, right: 18 }) @@ -74,7 +74,7 @@ export struct BitFunAccountLoginPage { Text(this.errorText) .fontSize(13) .lineHeight(19) - .fontColor('#D04A3A') + .fontColor(RED) .width('100%') .margin({ top: 12 }) } @@ -85,8 +85,8 @@ export struct BitFunAccountLoginPage { .width('100%') .fontSize(17) .fontWeight(FontWeight.Bold) - .fontColor(CARD) - .backgroundColor(INK) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(18) .margin({ top: this.errorText.length > 0 ? 22 : 30 }) .opacity(this.canSubmit() ? 1 : 0.28) @@ -104,10 +104,11 @@ export struct BitFunAccountLoginPage { .scrollBar(BarState.Off) Button() { - Image($r('app.media.remote_ref_back')) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23) + .fontColor([INK]) .width(26) .height(26) - .objectFit(ImageFit.Contain) } .width(44) .height(44) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 6e42f3b6e0..2235345a9a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,18 +1,16 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; import { FileReferenceCard } from './FileReferenceCard'; import { MarkdownContent } from './MarkdownContent'; -import { MarkdownParser, ParsedMarkdownBlock, ParsedMarkdownInline, ParsedMarkdownListItem } from '../../services/MarkdownParser'; import { StreamingMarkdownContent } from './StreamingMarkdownContent'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; - -interface FileReference { - id: string; - path: string; - label: string; -} +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -52,6 +50,8 @@ export struct ChatMessageBubble { @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @@ -60,10 +60,13 @@ export struct ChatMessageBubble { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; @Local expandedActivityPath: string = ''; @Local typingPhase: number = 0; private typingTimerId: number = 0; + private readonly fileReferenceCache: MessageFileReferenceProjectionCache = + new MessageFileReferenceProjectionCache(); aboutToAppear(): void { if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { @@ -93,18 +96,23 @@ export struct ChatMessageBubble { UserBubble() { Row() { Blank() - Column({ space: 8 }) { - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - .padding({ left: 16, right: 16, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(21) - } - if (this.item.images && this.item.images.length > 0) { - this.MessageImages(this.item.images) + Column({ space: 6 }) { + if (this.visibleMessageText(this.item).length > 0 || (this.item.images && this.item.images.length > 0)) { + Column({ space: 8 }) { + if (this.item.images && this.item.images.length > 0) { + this.UserMessageImages(this.item.images) + } + if (this.visibleMessageText(this.item).length > 0) { + Text(this.visibleMessageText(this.item)) + .fontSize(14) + .lineHeight(20) + .fontColor(INK) + } + } + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(18) + .alignItems(HorizontalAlign.Start) } if (this.item.status === 'failed') { Row({ space: 8 }) { @@ -113,7 +121,7 @@ export struct ChatMessageBubble { .fontColor(RED) Text(RemoteI18n.t('common.retry')) .fontSize(12) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .height(28) .padding({ left: 10, right: 10 }) .backgroundColor(ACCENT) @@ -124,7 +132,7 @@ export struct ChatMessageBubble { } } } - .width('70%') + .constraintSize({ maxWidth: '70%' }) .alignItems(HorizontalAlign.End) } .width('100%') @@ -150,7 +158,7 @@ export struct ChatMessageBubble { .fontColor(RED) Text(RemoteI18n.t('common.retry')) .fontSize(12) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .height(28) .padding({ left: 10, right: 10 }) .backgroundColor(ACCENT) @@ -215,12 +223,12 @@ export struct ChatMessageBubble { Text('') .width(6) .height(6) - .backgroundColor(CARD) + .backgroundColor(PRIMARY_ACTION_TEXT) .borderRadius(3) Text('') .width(6) .height(6) - .backgroundColor(CARD) + .backgroundColor(PRIMARY_ACTION_TEXT) .borderRadius(3) } .width(32) @@ -261,13 +269,15 @@ export struct ChatMessageBubble { }) } } else if (group.items.length > 0) { - this.StructuredItem( - group.items[0], - group.path, - group.itemStatuses[0] || '', - group.itemStreaming[0] || false, - group.itemChildActiveScopes[0] || false - ) + if (!omitActiveThinking || !this.isThinkingEntry(group.items[0])) { + this.StructuredItem( + group.items[0], + group.path, + group.itemStatuses[0] || '', + group.itemStreaming[0] || false, + group.itemChildActiveScopes[0] || false + ) + } } } @@ -356,7 +366,7 @@ export struct ChatMessageBubble { } .width('100%') .padding({ left: 10, right: 10, top: 8, bottom: 8 }) - .backgroundColor('#F7F7F4') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } @@ -403,6 +413,9 @@ export struct ChatMessageBubble { }, onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => { this.onAnswerQuestion(toolId, answers); + }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); } }) } @@ -456,6 +469,22 @@ export struct ChatMessageBubble { .width('100%') } + @Builder + UserMessageImages(images: ConversationUiImage[]) { + Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { + ForEach(images, (image: ConversationUiImage, index: number) => { + Image(image.data_url) + .width(112) + .height(112) + .objectFit(ImageFit.Cover) + .borderRadius(12) + .border({ width: 1, color: LINE }) + .margin({ right: index % 2 === 0 && images.length > 1 ? 8 : 0, bottom: index < images.length - 2 ? 8 : 0 }) + }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) + } + .width(images.length > 1 ? 232 : 112) + } + @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { if (active) { @@ -465,6 +494,9 @@ export struct ChatMessageBubble { streamKey, onCopyText: (body: string) => { this.onCopyMessage(body); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenFilePreview(reference, label); } }) } else { @@ -472,6 +504,9 @@ export struct ChatMessageBubble { text, onCopyText: (body: string) => { this.onCopyMessage(body); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenFilePreview(reference, label); } }) } @@ -480,18 +515,25 @@ export struct ChatMessageBubble { @Builder FileCards(text: string) { Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: FileReference) => { + ForEach(this.fileReferences(text), (file: MessageFileReference) => { FileReferenceCard({ path: file.path, label: file.label, status: this.fileStatus(file.path), + previewLabel: RemoteI18n.t('common.open'), buttonLabel: this.fileButtonLabel(file.path), disabled: this.downloadingFilePath === file.path, + selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + previewLoading: this.activeFilePreviewLoading && + FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + onPreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); + }, onDownload: (path: string) => { this.onDownloadFile(path); } }) - }, (file: FileReference) => file.id) + }, (file: MessageFileReference) => file.id) } .width('100%') } @@ -532,7 +574,6 @@ export struct ChatMessageBubble { private shouldPinThinkingToBottom(item: ConversationUiMessage): boolean { return this.isStreamingAssistant() && - !this.hasVisibleAssistantOutput(item) && this.currentThinkingText(item).length > 0; } @@ -1174,165 +1215,8 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): FileReference[] { - const matches: FileReference[] = []; - const seen = new Set(); - MarkdownParser.parse(text).forEach((block: ParsedMarkdownBlock) => { - this.collectInlineFileReferences(block.inlines, matches, seen); - block.items.forEach((item: ParsedMarkdownListItem) => { - this.collectInlineFileReferences(item.inlines, matches, seen); - }); - }); - return matches.slice(0, 4); - } - - private collectInlineFileReferences( - inlines: ParsedMarkdownInline[], - matches: FileReference[], - seen: Set - ): void { - inlines.forEach((inline: ParsedMarkdownInline) => { - if (inline.type === 'link') { - const linkTarget = this.downloadableLinkTarget(inline.url); - if (linkTarget.length > 0) { - this.addFileReference(linkTarget, matches, seen); - } - return; - } - if (inline.type !== 'code') { - this.collectRawComputerLinks(inline.text, matches, seen); - } - }); - } - - private collectRawComputerLinks(text: string, matches: FileReference[], seen: Set): void { - const found = text.match(/computer:\/\/[^\s)\]}>"']+/g) || []; - found.forEach((raw: string) => { - this.addFileReference(this.cleanFileLink(raw), matches, seen); - }); - } - - private addFileReference(path: string, matches: FileReference[], seen: Set): void { - const clean = this.cleanFileLink(path); - if (clean.length === 0 || seen.has(clean)) { - return; - } - seen.add(clean); - matches.push({ - id: `file-${matches.length}-${clean}`, - path: clean, - label: this.fileLabel(clean) - }); - } - - private downloadableLinkTarget(href: string): string { - const value = this.cleanFileLink(href); - if (value.indexOf('computer://') === 0) { - return value; - } - return this.localDownloadablePath(value); - } - - private localDownloadablePath(href: string): string { - if (href.length === 0 || href === '/') { - return ''; - } - if (href.indexOf('://') >= 0 && href.indexOf('file://') !== 0) { - return ''; - } - if (href.indexOf('#') === 0 || href.indexOf('//') === 0) { - return ''; - } - - const filePath = this.normalizeFileLikeHref(href); - if (filePath.length === 0) { - return ''; - } - - if (filePath.indexOf('/') === 0) { - const segments = filePath.split('/').filter((segment: string) => segment.length > 0); - if (segments.length < 2) { - return ''; - } - } - - const fileName = this.fileLabel(filePath); - const dotIndex = fileName.lastIndexOf('.'); - if (dotIndex <= 0) { - return ''; - } - - const extension = fileName.slice(dotIndex + 1).toLowerCase(); - if (extension.length === 0) { - return ''; - } - - if (filePath.indexOf('/') === 0 || this.isWindowsAbsolutePath(filePath)) { - return this.isCodeFileExtension(extension) ? '' : filePath; - } - return this.isDownloadableFileExtension(extension) ? filePath : ''; - } - - private normalizeFileLikeHref(rawHref: string): string { - let filePath = rawHref.trim(); - if (filePath.indexOf('file://') === 0) { - filePath = filePath.slice('file://'.length); - } else if (filePath.indexOf('file:') === 0) { - filePath = filePath.slice('file:'.length); - } - - const workspacePlaceholder = '{{workspaceFolder}}'; - if (filePath.indexOf(workspacePlaceholder) === 0) { - filePath = filePath.slice(workspacePlaceholder.length); - if (filePath.indexOf('/') === 0) { - filePath = filePath.slice(1); - } - } - - if (filePath.length >= 4 && filePath.charAt(0) === '/' && filePath.charAt(2) === ':' && - (filePath.charAt(3) === '/' || filePath.charAt(3) === '\\')) { - filePath = filePath.slice(1); - } - - try { - return decodeURIComponent(filePath); - } catch (_err) { - return filePath; - } - } - - private cleanFileLink(value: string): string { - let clean = value.trim(); - while (clean.length > 0) { - const last = clean.charAt(clean.length - 1); - if (last === ',' || last === '.' || last === ';' || last === ':' || last === ')' || - last === ']' || last === '}' || last === '>' || last === ',' || last === '。' || - last === ';' || last === ':') { - clean = clean.slice(0, clean.length - 1); - } else { - break; - } - } - return clean; - } - - private isWindowsAbsolutePath(path: string): boolean { - return path.length >= 3 && path.charAt(1) === ':' && - (path.charAt(2) === '/' || path.charAt(2) === '\\'); - } - - private isCodeFileExtension(extension: string): boolean { - return '|js|jsx|ts|tsx|mjs|cjs|mts|cts|py|pyw|pyi|rs|go|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|rb|php|swift|vue|svelte|css|scss|less|sass|json|jsonc|yaml|yml|toml|xml|md|mdx|rst|txt|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|lock|env|ini|cfg|conf|cj|ets|editorconfig|gitignore|log|'.indexOf(`|${extension}|`) >= 0; - } - - private isDownloadableFileExtension(extension: string): boolean { - return '|pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf|pages|numbers|key|png|jpg|jpeg|gif|bmp|svg|webp|ico|tiff|tif|zip|tar|gz|bz2|7z|rar|dmg|iso|xz|mp3|wav|ogg|flac|aac|m4a|wma|mp4|avi|mkv|mov|webm|wmv|flv|csv|tsv|sqlite|db|parquet|epub|mobi|apk|ipa|exe|msi|deb|rpm|ttf|otf|woff|woff2|'.indexOf(`|${extension}|`) >= 0; - } - - private fileLabel(path: string): string { - const normalized = path.replace(/^computer:\/\//, '').replace(/^file:\/\//, '').replace(/\\/g, '/'); - const parts = normalized.split('/'); - return parts[parts.length - 1] || normalized || 'file'; + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private fileStatus(path: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets index 6edbb59239..e61d366e2e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets @@ -1,11 +1,11 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; @Component export struct ChatStatusBar { @Prop title: string = ''; @Prop detail: string = ''; - @Prop color: string = MUTED; + @Prop color: ResourceColor = MUTED; @Prop canStop: boolean = false; onStop: () => void = () => {}; @@ -37,7 +37,7 @@ export struct ChatStatusBar { .fontWeight(FontWeight.Medium) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F0EFEC') + .backgroundColor(SOFT) .borderRadius(17) .onClick(() => { this.onStop(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 9f379a06a0..7fabd07bd4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -19,6 +19,9 @@ export struct ChatTimeline { @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Param maxContentWidth: number = 0; @Event onLoadOlder: () => void = () => {}; @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @@ -28,6 +31,7 @@ export struct ChatTimeline { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; build() { @@ -53,15 +57,14 @@ export struct ChatTimeline { .width('100%') .height('100%') .padding({ left: 20, right: 20, top: 0, bottom: 12 }) - .stackFromEnd(true) + .stackFromEnd(false) .scrollBar(BarState.Off) if (this.surface === ChatSurface.Remote && this.timelineItems.length > 2) { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_down')) - .width(18) - .height(18) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(18) + .fontColor([INK]) } .width(42) .height(42) @@ -76,6 +79,8 @@ export struct ChatTimeline { } .layoutWeight(1) .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : 10000 }) + .alignSelf(ItemAlign.Center) } @Builder @@ -144,6 +149,8 @@ export struct ChatTimeline { downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, onApproveTool: (toolId: string, updatedInput?: Object) => { this.onApproveTool(toolId, updatedInput); }, @@ -162,6 +169,9 @@ export struct ChatTimeline { onRetryMessage: (text: string) => { this.onRetryMessage(text); }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 284322c714..974055fe35 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -2,24 +2,52 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatComposerPolicy } from '../../services/ChatComposerPolicy'; import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { ChatSurface } from './ChatSurface'; -import { ConversationUiSelectedImage } from './ConversationUiModels'; -import { CARD, GREEN, INK, MUTED } from './Theme'; +import { + ConversationUiModel, + ConversationUiModelCatalog, + ConversationUiSelectedImage +} from './ConversationUiModels'; +import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; + +export enum ComposerPresentation { + Compact = 'compact', + Floating = 'floating', + Create = 'create' +} + +const COMPOSER_ACTION_SIZE: number = 40; +const COMPOSER_INPUT_HEIGHT: number = 42; +const COMPOSER_EXPANDED_INPUT_HEIGHT: number = 74; @ComponentV2 export struct ComposerBar { + @Param presentation: ComposerPresentation = ComposerPresentation.Compact; @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param chatInput: string = ''; @Local inputText: string = ''; + @Local inputFocused: boolean = false; @Param showQuickActions: boolean = false; @Param selectedImages: ConversationUiSelectedImage[] = []; @Param isBusy: boolean = false; + @Param canStop: boolean = false; @Param connectionState: string = 'connected'; @Param isVoiceListening: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Param selectedModelId: string = ''; + @Local showModelSelectorSheet: boolean = false; + @Local showModelSelectorPopover: boolean = false; @Event onToggleQuickActions: () => void = () => {}; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @Event onSend: () => void = () => {}; + @Event onStop: () => void = () => {}; @Event onVoiceInput: () => void = () => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Event onChatInputChange: (value: string) => void = (_value: string) => {}; aboutToAppear(): void { @@ -33,42 +61,248 @@ export struct ComposerBar { } } + @Monitor('presentation') + onPresentationChanged(): void { + this.closeModelSelector(); + } + build() { Column({ space: 8 }) { if (this.selectedImages.length > 0) { this.SelectedImageStrip() } - Row({ space: 5 }) { + this.AdaptiveComposer() + } + .width('100%') + .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) + .alignSelf(ItemAlign.Center) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 24 : 16, + right: this.presentation === ComposerPresentation.Floating ? 24 : 16, + top: 8, + bottom: this.presentation === ComposerPresentation.Floating ? 18 : 14 + }) + .backgroundColor('#00000000') + .bindSheet($$this.showModelSelectorSheet, this.ModelSelector(true), this.modelSelectorSheetOptions()) + } + + @Builder + AdaptiveComposer() { + Column({ space: 2 }) { + Row({ space: this.isComposerExpanded() ? 0 : 5 }) { if (this.shouldShowAddButton()) { - Stack({ alignContent: Alignment.Center }) { - this.PlusGlyph() + Row() { + this.AddButton() } - .width(36) - .height(44) - .onClick(() => { - if (this.isVoiceListening) { - return; - } - if (this.capabilities.supportsAttachments) { - this.onToggleQuickActions(); - return; - } - this.onPickImages(); - }) + .width(this.isComposerExpanded() ? 0 : COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .opacity(this.isComposerExpanded() ? 0 : 1) + .visibility(this.isComposerExpanded() ? Visibility.None : Visibility.Visible) + .clip(true) } this.InputField() - this.PrimaryActionButton() + Row() { + this.PrimaryActionButton() + } + .width(this.isComposerExpanded() ? 0 : COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .opacity(this.isComposerExpanded() ? 0 : 1) + .visibility(this.isComposerExpanded() ? Visibility.None : Visibility.Visible) + .clip(true) } .width('100%') - .height(52) - .padding({ left: 8, right: 3 }) - .backgroundColor(CARD) - .borderRadius(26) - .shadow({ radius: 24, color: '#18000000', offsetY: 7 }) + .height(this.isComposerExpanded() ? 76 : 52) + + if (this.isComposerExpanded()) { + Row({ space: 6 }) { + if (this.shouldShowAddButton()) { + this.AddButton() + } + if (this.shouldShowModelControl()) { + this.ModelControl() + } + Blank() + this.PrimaryActionButton() + } + .width('100%') + .height(44) + .padding({ left: 2, right: 0 }) + .alignItems(VerticalAlign.Center) + .transition(TransitionEffect.translate({ x: 0, y: 8 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } } .width('100%') - .padding({ left: 16, right: 16, top: 8, bottom: 14 }) + .height(this.isComposerExpanded() ? 126 : 52) + .padding({ + left: 8, + right: 8, + top: this.isComposerExpanded() ? 4 : 0, + bottom: this.isComposerExpanded() ? 2 : 0 + }) + .backgroundColor(CARD) + .borderRadius(this.isComposerExpanded() ? 18 : + (this.presentation === ComposerPresentation.Floating ? 18 : 26)) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 18 : 10, + color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 2 + }) + .animation({ duration: 220, curve: Curve.EaseOut }) + } + + @Builder + ModelControl() { + Row({ space: 3 }) { + Text(this.displaySelectedModel()) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 192 }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph(this.isModelSelectorExpanded() ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([INK]) + .opacity(0.68) + } + .width(16) + .height(34) + } + .height(34) + .constraintSize({ maxWidth: 220 }) + .padding({ left: 4, right: 4 }) .backgroundColor('#00000000') + .accessibilityText(`${RemoteI18n.t('chat.selectModel')} · ${this.displaySelectedModel()}`) + .bindPopup(this.showModelSelectorPopover, { + builder: () => { + this.ModelSelector(false) + }, + placement: Placement.Top, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + if (!event.isVisible) { + this.showModelSelectorPopover = false; + } + } + }) + .onClick(() => { + if (this.presentation === ComposerPresentation.Floating) { + this.showModelSelectorPopover = !this.showModelSelectorPopover; + } else { + this.showModelSelectorSheet = true; + } + }) + } + + @Builder + ModelSelector(asSheet: boolean) { + Column({ space: 10 }) { + if (asSheet) { + Row() { + Text(RemoteI18n.t('chat.selectModel')) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Blank() + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(15) + .fontColor([MUTED]) + .width(32) + .height(32) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.closeModelSelector(); + }) + } + .width('100%') + } + + List({ space: 6 }) { + ForEach(this.selectorModels(), (model: ConversationUiModel) => { + ListItem() { + this.ModelRow(model) + } + }, (model: ConversationUiModel) => model.id) + } + .width('100%') + .height(this.modelListHeight()) + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .divider(null) + } + .width(asSheet ? '100%' : 330) + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(asSheet ? CARD : FLOATING_PANEL_BG) + .borderRadius(asSheet ? { topLeft: 20, topRight: 20 } : 14) + .border({ width: asSheet ? 0 : 1, color: asSheet ? '#00000000' : LINE }) + .shadow({ radius: asSheet ? 0 : 18, color: asSheet ? '#00000000' : '#18000000', offsetY: 7 }) + } + + @Builder + ModelRow(model: ConversationUiModel) { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.isSelectedModel(model)) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + + Column({ space: 2 }) { + Text(ConversationModelPresentationPolicy.primaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(ConversationModelPresentationPolicy.secondaryLabel(model, RemoteI18n.t('chat.model'))) + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(48) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.isSelectedModel(model) ? SOFT : '#00000000') + .borderRadius(9) + .onClick(() => { + this.closeModelSelector(); + this.onSelectModel(model.id); + }) + } + + @Builder + AddButton() { + Stack({ alignContent: Alignment.Center }) { + this.PlusGlyph() + } + .width(COMPOSER_ACTION_SIZE) + .height(COMPOSER_ACTION_SIZE) + .onClick(() => { + if (this.isVoiceListening) { + return; + } + if (this.capabilities.supportsAttachments) { + this.onToggleQuickActions(); + return; + } + this.onPickImages(); + }) } @Builder @@ -77,16 +311,29 @@ export struct ComposerBar { if (this.isVoiceListening) { this.ListeningWave() } - TextInput({ placeholder: this.inputPlaceholder(), text: this.inputText }) + TextArea({ placeholder: this.inputPlaceholder(), text: this.inputText }) + .id('conversation-composer-input') .layoutWeight(1) - .height(42) + .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) .fontSize(16) .fontColor(INK) .placeholderColor(this.isVoiceListening ? GREEN : MUTED) .backgroundColor('#00000000') .borderRadius(20) - .padding({ left: this.isVoiceListening ? 0 : 4, right: 4 }) + .padding({ + left: this.isVoiceListening ? 0 : 4, + right: 4, + top: this.isComposerExpanded() ? 10 : 9, + bottom: this.isComposerExpanded() ? 8 : 9 + }) + .maxLines(this.isComposerExpanded() ? 4 : 1) .defaultFocus(false) + .onFocus(() => { + this.inputFocused = true; + }) + .onBlur(() => { + this.inputFocused = false; + }) .onChange((value: string, previewText?: PreviewText) => { // The first callback value excludes IME pre-edit text. Keep that text // inside the native field until the IME commits it. @@ -98,28 +345,29 @@ export struct ComposerBar { }) } .layoutWeight(1) - .height(42) + .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) + .alignItems(VerticalAlign.Center) .padding({ left: this.isVoiceListening ? 12 : 0, right: 0 }) - .backgroundColor(this.isVoiceListening ? '#F0FAF4' : '#00000000') + .backgroundColor(this.isVoiceListening ? SOFT : '#00000000') .borderRadius(20) - .border({ width: this.isVoiceListening ? 1 : 0, color: this.isVoiceListening ? '#BDE8CC' : '#00000000' }) + .border({ width: this.isVoiceListening ? 1 : 0, color: this.isVoiceListening ? GREEN : '#00000000' }) } @Builder PrimaryActionButton() { Button() { Stack({ alignContent: Alignment.Center }) { - if (this.isVoiceListening) { + if (this.isVoiceListening || this.canStop) { Text('') .width(13) .height(13) .backgroundColor(CARD) .borderRadius(3) } else if (this.hasComposedContent()) { - Image($r('app.media.gpt_composer_send')) - .width(39) - .height(39) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontWeight(FontWeight.Medium) + .fontColor([INK]) .opacity(this.canSend() ? 1 : 0.38) } else { this.MicrophoneGlyph() @@ -139,6 +387,10 @@ export struct ComposerBar { this.onVoiceInput(); return; } + if (this.canStop) { + this.onStop(); + return; + } if (this.canSend()) { this.onSend(); return; @@ -151,19 +403,17 @@ export struct ComposerBar { @Builder PlusGlyph() { - Image($r('app.media.gpt_composer_plus')) - .width(23) - .height(23) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.plus')) + .fontSize(22) + .fontColor([INK]) .opacity(this.isVoiceListening ? 0.36 : 1) } @Builder MicrophoneGlyph() { - Image($r('app.media.gpt_composer_mic')) - .width(22) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.mic')) + .fontSize(22) + .fontColor([INK]) .opacity(this.canUseVoice() ? 1 : 0.4) } @@ -259,13 +509,78 @@ export struct ComposerBar { return this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General; } + private isComposerExpanded(): boolean { + return this.inputFocused || this.showQuickActions || this.showModelSelectorSheet || + this.showModelSelectorPopover || this.inputText.indexOf('\n') >= 0; + } + + private isModelSelectorExpanded(): boolean { + return this.showModelSelectorSheet || this.showModelSelectorPopover; + } + + private shouldShowModelControl(): boolean { + return (this.capabilities.surface === ChatSurface.Remote || this.capabilities.surface === ChatSurface.General) && + this.enabledModels().length > 0; + } + + private enabledModels(): ConversationUiModel[] { + return ConversationModelPresentationPolicy.enabledModels(this.modelCatalog); + } + + private selectedModel(): ConversationUiModel | undefined { + return ConversationModelPresentationPolicy.selectedModel(this.modelCatalog, this.selectedModelId); + } + + private isSelectedModel(model: ConversationUiModel): boolean { + const selected = this.selectedModel(); + return !!selected && selected.id === model.id; + } + + private displaySelectedModel(): string { + const selected = this.selectedModel(); + return selected ? ConversationModelPresentationPolicy.primaryLabel(selected, RemoteI18n.t('chat.model')) : + RemoteI18n.t('chat.model'); + } + + private modelListHeight(): number { + const visibleRows = Math.min(this.enabledModels().length, 7); + return visibleRows * 48 + Math.max(0, visibleRows - 1) * 6; + } + + private selectorModels(): ConversationUiModel[] { + const models = this.enabledModels(); + const selected = this.selectedModel(); + if (!selected) { + return models; + } + return [selected, ...models.filter((model: ConversationUiModel) => model.id !== selected.id)]; + } + + private closeModelSelector(): void { + this.showModelSelectorSheet = false; + this.showModelSelectorPopover = false; + } + + private modelSelectorSheetOptions(): SheetOptions { + return { + height: Math.min(480, 86 + this.modelListHeight()), + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true + }; + } + private inputPlaceholder(): string { return this.isVoiceListening ? RemoteI18n.t('chat.voiceListeningPlaceholder') : RemoteI18n.t('chat.inputPlaceholder'); } - private actionBackgroundColor(): string { + private actionBackgroundColor(): ResourceColor { + if (this.canStop) { + return RED; + } if (this.isVoiceListening) { return GREEN; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 4af02a9561..8b917cecdd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -2,15 +2,11 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { ACCENT, CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; - -const CONNECT_HERO: string = '#E6EDFF'; -const CONNECT_HERO_BLUE: string = '#9DB4FF'; -const CONNECT_HERO_LILAC: string = '#C9C5FF'; -const CONNECT_HERO_MIST: string = '#F8FAFF'; +import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, + CONNECT_HERO_SURFACE, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + SUBTLE } from './Theme'; const CONNECT_SCAN_YELLOW: string = '#FFD021'; const CONNECT_OVERLAY: string = '#99000000'; -const CONNECT_DISABLED: string = '#D8D6D1'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; @Component @@ -146,7 +142,7 @@ export struct ConnectView { } .width('100%') .height('100%') - .backgroundColor('#FAFAF9') + .backgroundColor(PAGE_BG) } @Builder @@ -241,20 +237,22 @@ export struct ConnectView { .margin({ left: 16, right: 16 }) Row({ space: 12 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20) + .fontColor([MUTED]) .width(22) .height(22) - .objectFit(ImageFit.Contain) .opacity(0.66) Text(RemoteI18n.t('connect.scanPairCodeAction')) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(INK) .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(8) - .height(12) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13) + .fontColor([MUTED]) + .width(16) + .height(16) .opacity(0.44) } .width('100%') @@ -277,18 +275,18 @@ export struct ConnectView { Text('') .width(26) .height(22) - .backgroundColor('#ECECE9') + .backgroundColor(SOFT) .borderRadius(5) Column({ space: 7 }) { Text('') .width('58%') .height(12) - .backgroundColor('#ECECE9') + .backgroundColor(SOFT) .borderRadius(4) Text('') .width(52) .height(9) - .backgroundColor('#F0F0ED') + .backgroundColor(SOFT) .borderRadius(4) } .layoutWeight(1) @@ -303,8 +301,8 @@ export struct ConnectView { @Builder AccountConnectDeviceRow(device: CloudAccountDevice) { Row({ space: 12 }) { - Image($r('app.media.remote_ref_device')) - .width(26).height(24).objectFit(ImageFit.Contain).opacity(device.online ? 0.68 : 0.38) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) Column({ space: 3 }) { Text(device.deviceName || device.deviceId) .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) @@ -315,8 +313,8 @@ export struct ConnectView { .layoutWeight(1) .alignItems(HorizontalAlign.Start) if (device.online) { - Image($r('app.media.settings_chevron_right')) - .width(8).height(12).objectFit(ImageFit.Contain).opacity(0.44) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) } } .width('100%') @@ -391,7 +389,7 @@ export struct ConnectView { .fontColor(INK) .backgroundColor(CARD) .borderRadius(28) - .border({ width: 1.5, color: CONNECT_DISABLED }) + .border({ width: 1.5, color: LINE }) .margin({ bottom: 10 }) .onClick(() => { this.stopInlineScan(); @@ -451,7 +449,7 @@ export struct ConnectView { .fontColor(INK) .backgroundColor(CARD) .borderRadius(35) - .border({ width: 1.5, color: CONNECT_DISABLED }) + .border({ width: 1.5, color: LINE }) .onClick(() => { this.stopInlineScan(); this.showManualPairing = true; @@ -470,26 +468,26 @@ export struct ConnectView { Text('') .width('100%') .height(282) - .backgroundColor(CONNECT_HERO) + .backgroundColor(CONNECT_HERO_BG) .borderRadius(36) Text('') .width(260) .height(142) - .backgroundColor(CONNECT_HERO_MIST) + .backgroundColor(CONNECT_HERO_SURFACE) .opacity(0.7) .borderRadius(72) .position({ x: 112, y: 26 }) Text('') .width(188) .height(134) - .backgroundColor(CONNECT_HERO_BLUE) + .backgroundColor(CONNECT_HERO_ACCENT) .opacity(0.42) .borderRadius(68) .position({ x: -42, y: 198 }) Text('') .width(188) .height(126) - .backgroundColor(CONNECT_HERO_LILAC) + .backgroundColor(CONNECT_HERO_SECONDARY) .opacity(0.54) .borderRadius(64) .position({ x: 258, y: 0 }) @@ -521,10 +519,9 @@ export struct ConnectView { @Builder BackGlyph() { - Image($r('app.media.remote_ref_back')) - .width(12) - .height(20) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(21) + .fontColor([INK]) } @Builder @@ -581,8 +578,8 @@ export struct ConnectView { .height(58) .fontSize(21) .fontWeight(FontWeight.Bold) - .fontColor(CARD) - .backgroundColor(ACCENT) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(29) .margin({ bottom: 14 }) .onClick(() => { @@ -605,7 +602,7 @@ export struct ConnectView { } .height(30) .padding({ left: 12, right: 12 }) - .backgroundColor(this.isConnectError() ? '#FFF1F1' : SOFT) + .backgroundColor(SOFT) .borderRadius(15) .onClick(() => { this.handleStatusClick(); @@ -651,7 +648,7 @@ export struct ConnectView { .height(62) .fontSize(20) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(31) .padding({ left: 20, right: 20 }) .defaultFocus(true) @@ -663,7 +660,7 @@ export struct ConnectView { .height(56) .fontSize(18) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(28) .padding({ left: 20, right: 20 }) .onChange((value: string) => { @@ -673,7 +670,7 @@ export struct ConnectView { .height(56) .fontSize(18) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(28) .padding({ left: 20, right: 20 }) .type(InputType.Password) @@ -693,7 +690,7 @@ export struct ConnectView { .fontSize(19) .fontWeight(FontWeight.Bold) .fontColor(INK) - .backgroundColor('#ECEBE8') + .backgroundColor(SOFT) .borderRadius(29) .onClick(() => { this.stopInlineScan(); @@ -705,8 +702,8 @@ export struct ConnectView { .height(58) .fontSize(19) .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? CARD : SUBTLE) - .backgroundColor(this.canConnect() ? ACCENT : '#ECEBE8') + .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) .borderRadius(29) .enabled(this.canConnect()) .onClick(() => { @@ -720,9 +717,9 @@ export struct ConnectView { } .width('82%') .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor('#F6F6F3') + .backgroundColor(CARD) .borderRadius(34) - .border({ width: 1, color: '#FFFFFF' }) + .border({ width: 1, color: LINE }) } .width('100%') .height('100%') @@ -870,8 +867,8 @@ export struct ConnectView { .height(50) .fontSize(16) .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? CARD : SUBTLE) - .backgroundColor(this.canConnect() ? ACCENT : '#EDEBE6') + .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) .borderRadius(14) .enabled(this.canConnect()) .onClick(() => { @@ -992,7 +989,7 @@ export struct ConnectView { }) } - private statusDotColor(): string { + private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets index 71d1efcdd0..6531352eeb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets @@ -1,4 +1,5 @@ import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { FilePreviewRequest } from '../state/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', @@ -20,6 +21,7 @@ export enum ConversationIntentType { SelectModel = 'select_model', PickImages = 'pick_images', RemoveImage = 'remove_image', + OpenFilePreview = 'open_file_preview', DownloadFile = 'download_file', Send = 'send', VoiceInput = 'voice_input', @@ -32,19 +34,22 @@ export class ConversationIntent { readonly toolId: string; readonly updatedInput?: Object; readonly answers?: ConversationUiQuestionAnswer; + readonly filePreviewRequest?: FilePreviewRequest; constructor( type: ConversationIntentType, value: string = '', toolId: string = '', updatedInput?: Object, - answers?: ConversationUiQuestionAnswer + answers?: ConversationUiQuestionAnswer, + filePreviewRequest?: FilePreviewRequest ) { this.type = type; this.value = value; this.toolId = toolId; this.updatedInput = updatedInput; this.answers = answers; + this.filePreviewRequest = filePreviewRequest; } } @@ -68,4 +73,15 @@ export class ConversationIntents { static answerQuestion(toolId: string, answers: ConversationUiQuestionAnswer): ConversationIntent { return new ConversationIntent(ConversationIntentType.AnswerQuestion, '', toolId, undefined, answers); } + + static openFilePreview(reference: string, label: string): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.OpenFilePreview, + '', + '', + undefined, + undefined, + new FilePreviewRequest(reference, label) + ); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets index c030e3ec3e..f940c851e5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets @@ -13,7 +13,7 @@ export struct ConversationSourceSwitcher { this.SourceOption(ConversationSource.Remote, RemoteI18n.t('sidebar.code')) } .width('100%') - .height(42) + .height(40) .padding(3) .backgroundColor(SOFT) .borderRadius(8) @@ -24,9 +24,9 @@ export struct ConversationSourceSwitcher { private SourceOption(source: ConversationSource, label: string) { Text(label) .layoutWeight(1) - .height(34) - .fontSize(14) - .fontWeight(this.activeSource === source ? FontWeight.Bold : FontWeight.Medium) + .height(32) + .fontSize(13) + .fontWeight(FontWeight.Medium) .fontColor(this.activeSource === source ? INK : MUTED) .textAlign(TextAlign.Center) .backgroundColor(this.activeSource === source ? CARD : '#00000000') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 40a0ff0dac..3b1e9d679b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -1,3 +1,4 @@ +import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; @@ -12,10 +13,10 @@ import { ConversationUiSelectedImage, ConversationUiSession } from './ConversationUiModels'; -import { ComposerBar } from './ComposerBar'; +import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { GeneralChatHeader } from './GeneralChatHeader'; import { RemoteChatHeader } from './RemoteChatHeader'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; @ComponentV2 export struct ConversationView { @@ -50,14 +51,19 @@ export struct ConversationView { @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Param selectedImages: ConversationUiSelectedImage[] = []; @Param isVoiceListening: boolean = false; @Param chatInput: string = ''; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; + @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param contentHorizontalOffset: number = 0; @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; @Event onBack: () => void = () => {}; - @Event onNewSession: () => void = () => {}; @Param isSessionPinned: boolean = false; @Event onTogglePinSession: () => void = () => {}; @Event onArchiveSession: () => void = () => {}; @@ -74,6 +80,7 @@ export struct ConversationView { @Event onRenameSession: (title: string) => void = (_title: string) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Event onPickImages: () => void = () => {}; @Event onRemoveImage: (imageId: string) => void = (_imageId: string) => {}; @@ -84,28 +91,46 @@ export struct ConversationView { @Event onToggleQuickActions: () => void = () => {}; @Local showQuickActions: boolean = false; @Local showHeaderActions: boolean = false; + private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; + + aboutToAppear(): void { + this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); + } + + aboutToDisappear(): void { + this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); + } build() { Stack() { Column() { this.Header() - if (this.shouldShowStatusBar()) { - this.ExecutionStatusBar() - } - if (this.shouldShowSuggestions()) { - Blank().layoutWeight(1) - if (!this.isVoiceListening) { - this.PromptArea() + Column() { + if (this.shouldShowStatusBar()) { + this.ExecutionStatusBar() } - } else { - this.MessageList() - } - if (this.inlineStatusText.length > 0) { - Text(this.inlineStatusText) - .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') - .padding({ left: 20, right: 20, bottom: 8 }) + if (this.shouldShowSuggestions()) { + Blank().layoutWeight(1) + if (!this.isVoiceListening) { + this.PromptArea() + } + } else if (this.shouldCenterInlineStatus()) { + this.CenteredInlineStatus() + } else { + this.MessageList() + } + if (this.inlineStatusText.length > 0 && !this.shouldCenterInlineStatus()) { + Text(this.inlineStatusText) + .fontSize(12).lineHeight(18).fontColor(MUTED).width('100%') + .padding({ left: 20, right: 20, bottom: 8 }) + } + this.Composer() } - this.Composer() + .layoutWeight(1) + .width('100%') + .translate({ x: this.contentHorizontalOffset, y: 0 }) + .animation({ duration: 220, curve: Curve.EaseInOut }) } .width('100%').height('100%').backgroundColor(PAGE_BG) if (this.showQuickActions && this.composerCapabilities.supportsAttachments) { @@ -126,15 +151,19 @@ export struct ConversationView { Header() { if (this.surface === ChatSurface.General) { GeneralChatHeader({ + title: this.activeSession.title, showActions: ConversationViewContract.hasRealTimelineItem(this.timelineItems), showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, onOpenSidebar: () => { this.onOpenSidebar(); }, - onNewSession: () => { - this.showQuickActions = false; - this.showHeaderActions = false; - this.onNewSession(); + onRestoreSidebar: () => { + this.onRestoreSidebar(); + }, + onBack: () => { + this.onBack(); }, onOpenActions: () => { this.showQuickActions = false; @@ -146,24 +175,20 @@ export struct ConversationView { activeSession: this.activeSession, workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, - canStop: this.canStop, - modelCatalog: this.modelCatalog, - selectedModelId: this.selectedModelId, showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, onBack: () => { this.onBack(); }, - onNewSession: () => { - this.onNewSession(); + onRestoreSidebar: () => { + this.onRestoreSidebar(); }, - onStop: () => { - this.onStop(); + onOpenActions: () => { + this.showQuickActions = false; + this.showHeaderActions = !this.showHeaderActions; }, onRenameSession: (title: string) => { this.onRenameSession(title); - }, - onSelectModel: (modelId: string) => { - this.onSelectModel(modelId); } }) } @@ -195,6 +220,9 @@ export struct ConversationView { downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0, onLoadOlder: () => { this.onLoadOlder(); }, @@ -216,12 +244,31 @@ export struct ConversationView { onRetryMessage: (text: string) => { this.onRetryMessage(text); }, + onOpenFilePreview: (path: string, label: string) => { + this.onOpenFilePreview(path, label); + }, onDownloadFile: (path: string) => { this.onDownloadFile(path); } }) } + @Builder + CenteredInlineStatus() { + Stack({ alignContent: Alignment.Center }) { + Text(this.inlineStatusText) + .fontSize(14) + .lineHeight(20) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .maxLines(2) + } + .layoutWeight(1) + .width('100%') + .height('100%') + .padding({ left: 32, right: 32, bottom: 48 }) + } + @Builder PromptArea() { Column({ space: 15 }) { @@ -259,6 +306,7 @@ export struct ConversationView { @Builder Composer() { ComposerBar({ + presentation: this.composerPresentation, capabilities: this.composerCapabilities, chatInput: this.chatInput, showQuickActions: this.showQuickActions, @@ -267,8 +315,11 @@ export struct ConversationView { }, selectedImages: this.selectedImages, isBusy: this.isBusy, + canStop: this.canStop, connectionState: this.connectionState, isVoiceListening: this.isVoiceListening, + modelCatalog: this.modelCatalog, + selectedModelId: this.selectedModelId, onPickImages: () => { this.onPickImages(); }, @@ -278,9 +329,15 @@ export struct ConversationView { onSend: () => { this.onSend(); }, + onStop: () => { + this.onStop(); + }, onVoiceInput: () => { this.onVoiceInput(); }, + onSelectModel: (modelId: string) => { + this.onSelectModel(modelId); + }, onChatInputChange: (value: string) => { this.onChatInputChange(value); } @@ -318,51 +375,79 @@ export struct ConversationView { } .width('100%') .padding({ left: 18, right: 18, top: 12, bottom: 10 }) - .backgroundColor(CARD) - .border({ width: { top: 1 }, color: LINE }) } @Builder MenuBackdrop(onClose: () => void) { Text('') .width('100%').height('100%') - .backgroundColor('#18000000') + .backgroundColor(this.composerPresentation === ComposerPresentation.Floating ? '#00000000' : '#18000000') .zIndex(5) .onClick(onClose) } @Builder QuickActionsMenu() { - Column({ space: 4 }) { - this.MenuItem('gpt_home_image_glyph', '相机', () => this.onPickImages()) - this.MenuItem('gpt_home_image_glyph', '照片', () => this.onPickImages()) - this.MenuItem('remote_actions_link', '文件', () => {}) - this.MenuItem('remote_actions_chat', '插件', () => {}) - this.MenuItem('remote_actions_settings', '智能', () => {}) + if (this.composerPresentation === ComposerPresentation.Floating) { + this.QuickActionsPopover() + } else { + this.QuickActionsBottomSheet() } - .width(300).padding({ left: 18, right: 18, top: 14, bottom: 14 }) - .backgroundColor('#FDFDFD') - .borderRadius(24) - .shadow({ radius: 24, color: '#26000000', offsetY: 8 }) - .position({ left: 28, bottom: 72 }) + } + + @Builder + QuickActionsPopover() { + Column() { + this.AttachmentPanel() + } + .width(360) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(18) + .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) + .position({ left: 80 + this.contentHorizontalOffset, bottom: 86 }) .zIndex(6) - .transition(TransitionEffect.translate({ x: 0, y: 18 }) + .transition(TransitionEffect.translate({ x: 0, y: 14 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseOut })) + } + + @Builder + QuickActionsBottomSheet() { + Column() { + Text('') + .width(36) + .height(4) + .backgroundColor(LINE) + .borderRadius(2) + .margin({ top: 10 }) + this.AttachmentPanel() + } + .width('100%') + .backgroundColor(CARD) + .border({ width: { top: 1 }, color: LINE }) + .borderRadius({ topLeft: 18, topRight: 18 }) + .position({ left: 0, bottom: 0 }) + .zIndex(6) + .alignItems(HorizontalAlign.Center) + .transition(TransitionEffect.translate({ x: 0, y: 24 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 220, curve: Curve.EaseOut })) } @Builder HeaderActionsMenu() { + if (this.composerPresentation === ComposerPresentation.Floating) { + this.HeaderActionsPopover() + } else { + this.HeaderActionsBottomSheet() + } + } + + @Builder + HeaderActionsPopover() { Column() { - Text('会话') - .fontSize(13).fontWeight(FontWeight.Medium).fontColor('#8E8E93') - .width('100%').height(28).padding({ left: 8 }) - this.RemoteStyleMenuItem('remote_actions_check', - this.isSessionPinned ? '取消置顶' : '置顶', () => this.onTogglePinSession(), this.isSessionPinned) - this.RemoteStyleMenuItem('remote_actions_cloud', '已上传的文件', () => this.onShowUploadedFiles()) - Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) - this.RemoteStyleMenuItem('remote_actions_folder', '归档', () => this.onArchiveSession()) - this.RemoteStyleMenuItem('remote_actions_settings', '删除', () => this.onDeleteSession()) + this.HeaderActionsContent() } .width(330) .padding({ left: 16, right: 16, top: 14, bottom: 14 }) @@ -378,21 +463,47 @@ export struct ConversationView { } @Builder - MenuItem(icon: string, label: string, action: () => void) { - Row({ space: 18 }) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.' + icon)) - .width(25).height(25).objectFit(ImageFit.Contain) - } - .width(40).height(40).backgroundColor('#F2F2F2').borderRadius(20) - Text(label).fontSize(17).fontColor(INK).layoutWeight(1) + HeaderActionsBottomSheet() { + Column() { + Text('') + .width(36) + .height(4) + .backgroundColor(LINE) + .borderRadius(2) + .margin({ bottom: 8 }) + this.HeaderActionsContent() + } + .width('100%') + .padding({ left: 16, right: 16, top: 10, bottom: 20 }) + .backgroundColor(CARD) + .border({ width: { top: 1 }, color: LINE }) + .borderRadius({ topLeft: 18, topRight: 18 }) + .position({ left: 0, bottom: 0 }) + .zIndex(6) + .alignItems(HorizontalAlign.Center) + .transition(TransitionEffect.translate({ x: 0, y: 24 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseOut })) + } + + @Builder + HeaderActionsContent() { + Text('会话') + .fontSize(13).fontWeight(FontWeight.Medium).fontColor(MUTED) + .width('100%').height(28).padding({ left: 8 }) + if (this.surface === ChatSurface.General) { + this.RemoteStyleMenuItem('remote_actions_check', + this.isSessionPinned ? '取消置顶' : '置顶', () => this.onTogglePinSession(), this.isSessionPinned) + } + this.RemoteStyleMenuItem('remote_actions_cloud', '已上传的文件', () => this.onShowUploadedFiles()) + if (this.surface === ChatSurface.General) { + Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) + this.RemoteStyleMenuItem('remote_actions_folder', '归档', () => this.onArchiveSession()) + this.RemoteStyleMenuItem('remote_actions_settings', '删除', () => this.onDeleteSession()) + } else if (this.canStop) { + Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) + this.RemoteStyleMenuItem('remote_actions_settings', RemoteI18n.t('chat.stop'), () => this.onStop()) } - .width('100%').height(56) - .onClick(() => { - action(); - this.showQuickActions = false; - this.showHeaderActions = false; - }) } @Builder @@ -406,7 +517,7 @@ export struct ConversationView { .width('100%').height(48) .padding({ left: 8, right: 8 }) .borderRadius(10) - .backgroundColor(selected ? '#F3F3F3' : '#00000000') + .backgroundColor(selected ? SOFT : '#00000000') .onClick(() => { action() this.showHeaderActions = false @@ -416,15 +527,15 @@ export struct ConversationView { @Builder MenuIcon(icon: string) { if (icon === 'remote_actions_link') { - Image($r('app.media.remote_actions_link')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.link')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_check') { - Image($r('app.media.remote_actions_check')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.checkmark_circle')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_folder') { - Image($r('app.media.remote_actions_folder')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_cloud') { - Image($r('app.media.remote_actions_cloud')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.cloud')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else { - Image($r('app.media.remote_actions_settings')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.gearshape')).fontSize(20).fontColor([MUTED]).width(23).height(23) } } @@ -437,7 +548,7 @@ export struct ConversationView { .fontSize(21) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F2F1EE') + .backgroundColor(SOFT) .borderRadius(16) Column({ space: 3 }) { Text(RemoteI18n.t('chat.pickImage')) @@ -459,7 +570,7 @@ export struct ConversationView { .width('100%') .height(48) .padding({ left: 12, right: 12 }) - .backgroundColor('#FAFAF8') + .backgroundColor(CARD) .borderRadius(14) .border({ width: 1, color: LINE }) .onClick(() => { @@ -503,15 +614,13 @@ export struct ConversationView { .fontWeight(FontWeight.Medium) .fontColor(MUTED) } else if (kind === 'globe') { - Image($r('app.media.gpt_home_globe_glyph')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.website')) + .fontSize(21) + .fontColor([MUTED]) } else if (kind === 'image') { - Image($r('app.media.gpt_home_image_glyph')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.picture')) + .fontSize(21) + .fontColor([MUTED]) } else { this.FileGlyph() } @@ -544,7 +653,14 @@ export struct ConversationView { this.showSuggestionsWhenEmpty, this.isBusy, this.timelineItems - ); + ) && (this.supportsSearch || this.supportsImages || this.supportsFiles); + } + + private shouldCenterInlineStatus(): boolean { + return this.inlineStatusText.length > 0 && + !this.isBusy && + !this.shouldShowSuggestions() && + !ConversationViewContract.hasRealTimelineItem(this.timelineItems); } private visibleTimelineItems(): ChatTimelineItem[] { @@ -558,7 +674,7 @@ export struct ConversationView { return ConnectionStatusPresenter.detail(this.connectionState, this.statusText, '', ''); } - private statusColor(): string { + private statusColor(): ResourceColor { if (this.canStop && this.connectionState === 'connected') { return GREEN; } @@ -587,7 +703,7 @@ export struct ConversationView { return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } - private connectionColor(): string { + private connectionColor(): ResourceColor { const tone = ConnectionStatusPresenter.tone(this.connectionState); if (tone === 'ok') { return GREEN; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 0782b9ed54..1c6bae8410 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -6,12 +6,19 @@ import { ConversationIntentType } from './ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ComposerPresentation } from './ComposerBar'; @ComponentV2 export struct ConversationViewHost { @Param viewState: ConversationViewState = new ConversationViewState(); + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; + @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; + @Param contentHorizontalOffset: number = 0; + @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; build() { @@ -38,15 +45,20 @@ export struct ConversationViewHost { downloadingFilePath: this.viewState.downloadingFilePath, downloadedFilePath: this.viewState.downloadedFilePath, fileDownloadStatus: this.viewState.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, selectedImages: this.viewState.selectedImages, isVoiceListening: this.viewState.isVoiceListening, chatInput: this.viewState.chatInput, isSessionPinned: this.viewState.isSessionPinned, showSidebarButton: this.showSidebarButton, showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.composerPresentation, + contentHorizontalOffset: this.contentHorizontalOffset, onOpenSidebar: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.OpenSidebar)), + onRestoreSidebar: this.onRestoreSidebar, onBack: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Back)), - onNewSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.NewSession)), onTogglePinSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.TogglePin)), onArchiveSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Archive)), onDeleteSession: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.Delete)), @@ -61,6 +73,8 @@ export struct ConversationViewHost { onRenameSession: (title: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RenameSession, title)), onCopyMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.CopyMessage, text)), onRetryMessage: (text: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RetryMessage, text)), + onOpenFilePreview: (path: string, label: string) => + this.dispatch(ConversationIntents.openFilePreview(path, label)), onSelectModel: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.SelectModel, id)), onPickImages: () => this.dispatch(ConversationIntents.simple(ConversationIntentType.PickImages)), onRemoveImage: (id: string) => this.dispatch(ConversationIntents.value(ConversationIntentType.RemoveImage, id)), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets new file mode 100644 index 0000000000..0ecc0f6d0d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -0,0 +1,397 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; + +@ComponentV2 +struct WorkspaceFilterSection { + @Param options: RecentWorkspaceEntry[] = []; + @Param selectedValue: string = ''; + @Event onSelect: (value: string) => void = (_value: string) => {}; + @Local currentValue: string = ''; + + aboutToAppear(): void { + this.currentValue = this.selectedValue; + } + + build() { + Column() { + Row({ space: 12 }) { + SymbolGlyph(this.currentValue.length === 0 ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([this.currentValue.length === 0 ? INK : MUTED]) + .width(22) + Text(RemoteI18n.t('viewSettings.allWorkspaces')) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.currentValue.length === 0 ? CARD : '#00000000') + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectWorkspace('')) + ForEach(this.options, (item: RecentWorkspaceEntry) => { + Row({ space: 12 }) { + SymbolGlyph(ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) + ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) ? INK : MUTED]) + .width(22) + Text(item.name || this.basename(item.path)) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(ConversationSessionFilterPolicy.workspacePathsEqual(this.currentValue, item.path) + ? CARD : '#00000000') + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectWorkspace(item.path)) + }, (item: RecentWorkspaceEntry): string => item.path) + } + .width('100%') + } + + private selectWorkspace(value: string): void { + this.currentValue = value; + setTimeout(() => this.onSelect(value), 0); + } + + private basename(path: string): string { + const parts = path.split('/'); + return parts.length > 0 ? parts[parts.length - 1] : path; + } +} + +@ComponentV2 +export struct ConversationViewSettings { + @Param sessions: RemoteSession[] = []; + @Param workspaceName: string = ''; + @Param workspacePath: string = ''; + @Param workspaceKind: string = 'normal'; + @Param recentWorkspaces: RecentWorkspaceEntry[] = []; + @Param sortMode: string = 'project'; + @Param workspaceFilter: string = ''; + @Param agentFilter: string = ''; + @Param statusFilter: string = ''; + @Param showWorkspaceMetadata: boolean = false; + @Param showUpdatedMetadata: boolean = false; + @Param showStatusMetadata: boolean = false; + @Local selectedSortMode: string = ''; + @Local selectedWorkspaceFilter: string = ''; + @Local selectedAgentFilter: string = ''; + @Local selectedStatusFilter: string = ''; + @Local selectionRevision: number = 0; + @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; + @Event onWorkspaceFilterChange: (value: string) => void = (_value: string) => {}; + @Event onAgentFilterChange: (value: string) => void = (_value: string) => {}; + @Event onStatusFilterChange: (value: string) => void = (_value: string) => {}; + @Event onWorkspaceMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onClose: () => void = () => {}; + + aboutToAppear(): void { + this.syncSelectionState(); + } + + build() { + Column() { + Row() { + Column({ space: 3 }) { + Text(RemoteI18n.t('viewSettings.title')) + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(RemoteI18n.t('viewSettings.subtitle')) + .fontSize(12) + .fontColor(MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(64) + .padding({ left: 20, right: 10 }) + + Divider().color(LINE) + + Scroll() { + Column({ space: 0 }) { + this.SectionTitle(RemoteI18n.t('viewSettings.grouping')) + this.SortRow('project', RemoteI18n.t('remote.menu.byProject'), this.selectionRevision) + this.SortRow('time', RemoteI18n.t('remote.menu.byTime'), this.selectionRevision) + this.SortRow('chat', RemoteI18n.t('remote.menu.chatFirst'), this.selectionRevision) + this.SectionTitle(RemoteI18n.t('viewSettings.filters')) + this.FilterLabel(RemoteI18n.t('viewSettings.workspace')) + WorkspaceFilterSection({ + options: this.workspaceFilterOptions(), + selectedValue: this.workspaceFilter, + onSelect: (value: string) => this.selectWorkspaceFilter(value) + }) + this.FilterLabel(RemoteI18n.t('viewSettings.agentType')) + this.ChoiceRow('', RemoteI18n.t('viewSettings.allAgentTypes'), this.selectedAgentFilter.length === 0, + (value: string) => this.selectAgentFilter(value), this.selectionRevision) + ForEach(this.agentFilterOptions(), (value: string) => { + this.ChoiceRow(value, this.agentFilterLabel(value), this.selectedAgentFilter === value, + (nextValue: string) => this.selectAgentFilter(nextValue), this.selectionRevision) + }, (value: string): string => value) + this.FilterLabel(RemoteI18n.t('viewSettings.status')) + this.ChoiceRow('', RemoteI18n.t('viewSettings.allStatuses'), this.selectedStatusFilter.length === 0, + (value: string) => this.selectStatusFilter(value), this.selectionRevision) + ForEach(this.statusFilterOptions(), (value: string) => { + this.ChoiceRow(value, this.statusFilterLabel(value), this.selectedStatusFilter === value, + (nextValue: string) => this.selectStatusFilter(nextValue), this.selectionRevision) + }, (value: string): string => value) + this.SectionTitle(RemoteI18n.t('viewSettings.metadata')) + this.ToggleRow(RemoteI18n.t('viewSettings.workspace'), this.showWorkspaceMetadata, + this.onWorkspaceMetadataChange) + this.ToggleRow(RemoteI18n.t('viewSettings.updated'), this.showUpdatedMetadata, + this.onUpdatedMetadataChange) + this.ToggleRow(RemoteI18n.t('viewSettings.status'), this.showStatusMetadata, + this.onStatusMetadataChange) + } + .width('100%') + .padding({ left: 20, right: 20, bottom: 24 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius(16) + } + + @Builder + private FilterLabel(label: string) { + Text(label) + .width('100%') + .height(34) + .padding({ left: 10, top: 10 }) + .fontSize(12) + .fontColor(MUTED) + } + + @Builder + private SectionTitle(title: string) { + Text(title) + .width('100%') + .height(38) + .padding({ left: 4, top: 12 }) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + } + + @Builder + private SortRow(mode: string, label: string, _revision: number) { + Row({ space: 12 }) { + SymbolGlyph(this.selectedSortMode === mode ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([this.selectedSortMode === mode ? INK : MUTED]) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + } + .width('100%') + .height(48) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.selectedSortMode === mode ? CARD : '#00000000') + .opacity(_revision % 2 === 0 ? 1 : 0.999) + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => this.selectSortMode(mode)) + } + + @Builder + private ChoiceRow(value: string, label: string, selected: boolean, action: (value: string) => void, + _revision: number) { + Row({ space: 12 }) { + SymbolGlyph(selected ? $r('sys.symbol.checkmark_circle_fill') : $r('sys.symbol.circle')) + .fontSize(20) + .fontColor([selected ? INK : MUTED]) + .width(22) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .backgroundColor(selected ? CARD : '#00000000') + .opacity(_revision % 2 === 0 ? 1 : 0.999) + .border({ width: { bottom: 1 }, color: LINE }) + .onClick(() => action(value)) + } + + @Builder + private ToggleRow(label: string, value: boolean, action: (value: boolean) => void) { + Row({ space: 12 }) { + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + Toggle({ type: ToggleType.Switch, isOn: value }) + .selectedColor(INK) + .onChange(action) + } + .width('100%') + .height(52) + .padding({ left: 10, right: 6 }) + .border({ width: { bottom: 1 }, color: LINE }) + } + + private workspaceFilterOptions(): RecentWorkspaceEntry[] { + const result: RecentWorkspaceEntry[] = []; + if (this.workspacePath.length > 0) { + result.push({ path: this.workspacePath, name: this.workspaceName, lastOpened: '', workspaceKind: 'normal' }); + } + this.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { + if (item.path.length > 0 && !result.some((entry: RecentWorkspaceEntry) => { + return ConversationSessionFilterPolicy.workspacePathsEqual(entry.path, item.path); + })) { + result.push(item); + } + }); + this.sessions.forEach((session: RemoteSession) => { + const path = session.workspacePath || ''; + if (path.length > 0 && !result.some((entry: RecentWorkspaceEntry) => { + return ConversationSessionFilterPolicy.workspacePathsEqual(entry.path, path); + })) { + result.push({ + path, + name: session.workspaceName || this.basename(path), + lastOpened: session.updatedAt, + workspaceKind: 'normal' + }); + } + }); + return result; + } + + private syncSelectionState(): void { + this.selectedSortMode = this.sortMode; + this.selectedWorkspaceFilter = this.workspaceFilter; + this.selectedAgentFilter = this.agentFilter; + this.selectedStatusFilter = this.statusFilter; + } + + private selectSortMode(mode: string): void { + this.selectedSortMode = mode; + this.selectionRevision += 1; + } + + private selectWorkspaceFilter(value: string): void { + RemoteLogger.info(`view-settings select workspace path=${value.length > 0 ? value : ''}`); + this.selectedWorkspaceFilter = value; + this.selectionRevision += 1; + this.onWorkspaceFilterChange(value); + } + + private selectAgentFilter(value: string): void { + this.selectedAgentFilter = value; + this.selectionRevision += 1; + } + + private selectStatusFilter(value: string): void { + this.selectedStatusFilter = value; + this.selectionRevision += 1; + } + + private applySelectionAndClose(): void { + RemoteLogger.info(`view-settings apply workspace=${this.selectedWorkspaceFilter.length > 0 ? + this.selectedWorkspaceFilter : ''}`); + this.onSortModeChange(this.selectedSortMode); + this.onWorkspaceFilterChange(this.selectedWorkspaceFilter); + this.onAgentFilterChange(this.selectedAgentFilter); + this.onStatusFilterChange(this.selectedStatusFilter); + this.onClose(); + } + + private agentFilterOptions(): string[] { + const values: string[] = []; + this.sessions.forEach((session: RemoteSession) => { + const value = this.agentGroup(session); + if (value.length > 0 && values.indexOf(value) < 0) { + values.push(value); + } + }); + return ['chat', 'code', 'cowork'].filter((value: string) => values.indexOf(value) >= 0); + } + + private statusFilterOptions(): string[] { + const values: string[] = []; + this.sessions.forEach((session: RemoteSession) => { + const value = (session.status || '').trim().toLowerCase(); + if (value.length > 0 && values.indexOf(value) < 0) { + values.push(value); + } + }); + return values.sort(); + } + + private agentGroup(session: RemoteSession): string { + const value = (session.agentType || '').toLowerCase(); + if (value === 'claw' || value === 'assistant' || value === 'chat' || + this.isAssistantWorkspace(session.workspacePath || '')) { + return 'chat'; + } + return value === 'cowork' ? 'cowork' : 'code'; + } + + private agentFilterLabel(value: string): string { + if (value === 'chat') { + return RemoteI18n.t('remote.create.chat'); + } + return value === 'cowork' ? 'Cowork' : 'Code'; + } + + private statusFilterLabel(value: string): string { + if (value === 'active' || value === 'running') { + return RemoteI18n.t('common.running'); + } + if (value === 'ready' || value === 'idle') { + return RemoteI18n.t('common.ready'); + } + if (value === 'archived') { + return RemoteI18n.t('sidebar.archived'); + } + return value; + } + + private basename(path: string): string { + const parts = path.split('/'); + return parts.length > 0 ? parts[parts.length - 1] : path; + } + + private isAssistantWorkspace(path: string): boolean { + if (path.length === 0) { + return this.workspaceKind.toLowerCase() === 'assistant'; + } + if (path === this.workspacePath && this.workspaceKind.toLowerCase() === 'assistant') { + return true; + } + return this.recentWorkspaces.some((item: RecentWorkspaceEntry) => { + return item.path === path && item.workspaceKind.toLowerCase() === 'assistant'; + }); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets index a68348cfeb..7ad51ec5d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; @Component export struct CreateSessionSheet { @@ -87,8 +87,8 @@ export struct CreateSessionSheet { .height(56) .fontSize(17) .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(ACCENT) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(14) .enabled(!this.isBusy) .onClick(() => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index fddc268c53..72fb3c48cc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -1,5 +1,4 @@ -const AVATAR_BACKGROUND: string = '#ECEEF1'; -const AVATAR_FOREGROUND: string = '#626268'; +import { MUTED, SOFT } from './Theme'; @Component export struct DefaultAccountAvatar { @@ -9,11 +8,11 @@ export struct DefaultAccountAvatar { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.person')) .fontSize(this.avatarSize * 0.52) - .fontColor([AVATAR_FOREGROUND]) + .fontColor([MUTED]) } .width(this.avatarSize) .height(this.avatarSize) - .backgroundColor(AVATAR_BACKGROUND) + .backgroundColor(SOFT) .borderRadius(this.avatarSize / 2) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets new file mode 100644 index 0000000000..de45baf077 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + CodeSyntaxHighlightCache, + CodeSyntaxHighlighter, + CodeSyntaxToken, + CodeSyntaxTokenKind +} from '../../services/CodeSyntaxHighlighter'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + FilePreviewPhase, + FilePreviewRendererKind, + FilePreviewState +} from '../state/FilePreviewState'; +import { MarkdownContent } from './MarkdownContent'; +import { + CARD, + CODE_COMMENT, + CODE_CONSTANT, + CODE_FUNCTION, + CODE_KEYWORD, + CODE_LINE_NUMBER, + CODE_NUMBER, + CODE_PROPERTY, + CODE_STRING, + CODE_TARGET_BG, + CODE_TYPE, + INK, + LINE, + MUTED, + PAGE_BG, + SOFT +} from './Theme'; + +@ComponentV2 +export struct FilePreviewSurface { + private readonly textScroller: Scroller = new Scroller(); + private readonly markdownScroller: Scroller = new Scroller(); + private readonly syntaxHighlightCache: CodeSyntaxHighlightCache = new CodeSyntaxHighlightCache(); + private restoreScrollTimerId: number = 0; + @Local imageOriginalSizeTarget: string = ''; + @Param state: FilePreviewState = new FilePreviewState(); + @Param remoteAvailable: boolean = true; + @Param downloadPath: string = ''; + @Param downloadedPath: string = ''; + @Param downloadStatus: string = ''; + @Event onClose: () => void = () => {}; + @Event onRefresh: () => void = () => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + + aboutToDisappear(): void { + this.clearRestoreScrollTimer(); + } + + build() { + Column() { + this.Header() + this.DownloadStatus() + this.Body() + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + Header() { + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.onClose(); + }) + + Column({ space: 2 }) { + Text(this.state.fileName || this.state.target.displayName || RemoteI18n.t('filePreview.title')) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.headerDetail()) + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + if (this.isImageReady()) { + Stack({ alignContent: Alignment.Center }) { + Text('1:1') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.isImageOriginalSize() ? CODE_FUNCTION : INK) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t( + this.isImageOriginalSize() ? 'filePreview.fitImage' : 'filePreview.actualImageSize' + )) + .onClick(() => { + this.toggleImageSize(); + }) + } + + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.arrow_clockwise')) + .fontSize(20) + .fontColor([this.canUseRemoteAction() ? INK : MUTED]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.refresh')) + .opacity(this.canUseRemoteAction() ? 1 : 0.45) + .onClick(() => { + if (this.canUseRemoteAction()) { + this.onRefresh(); + } + }) + + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.arrow_down_to_line')) + .fontSize(20) + .fontColor([this.canDownload() ? INK : MUTED]) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('chat.download')) + .opacity(this.canDownload() ? 1 : 0.45) + .onClick(() => { + if (this.canDownload()) { + this.onDownload(this.state.target.rawReference || this.state.target.remotePath); + } + }) + } + .width('100%') + .height(68) + .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + .backgroundColor(PAGE_BG) + } + + @Builder + Body() { + if (this.state.phase === FilePreviewPhase.Loading && !this.remoteAvailable) { + this.OfflineLoadingState() + } else if (this.state.phase === FilePreviewPhase.Loading) { + this.LoadingState() + } else if (this.state.phase === FilePreviewPhase.Error) { + this.ErrorState() + } else if (this.state.phase === FilePreviewPhase.Unsupported) { + this.UnsupportedState() + } else if (this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Image) { + this.ImagePreview() + } else if (this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Markdown) { + this.MarkdownPreview() + } else if (this.state.phase === FilePreviewPhase.Ready) { + this.TextPreview() + } else { + this.EmptyState() + } + } + + @Builder + DownloadStatus() { + if (this.downloadStatus.length > 0 && this.downloadMatchesTarget()) { + Row() { + Text(this.downloadStatus) + .width('100%') + .fontSize(12) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .padding({ left: 14, right: 14, top: 8, bottom: 8 }) + .backgroundColor(SOFT) + .border({ width: { bottom: 1 }, color: LINE }) + } + } + + @Builder + LoadingState() { + Column({ space: 12 }) { + LoadingProgress() + .width(28) + .height(28) + .color(MUTED) + Text(RemoteI18n.t('filePreview.loading')) + .fontSize(14) + .fontColor(MUTED) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + OfflineLoadingState() { + Column({ space: 10 }) { + Text(RemoteI18n.t('filePreview.loadFailed')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(RemoteI18n.t('filePreview.offline')) + .fontSize(13) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + ErrorState() { + Column({ space: 12 }) { + Text(RemoteI18n.t('filePreview.loadFailed')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.state.errorText) + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.state.errorRetryable) { + Button(RemoteI18n.t('common.retry')) + .height(42) + .fontSize(14) + .fontColor(CARD) + .backgroundColor(INK) + .borderRadius(21) + .enabled(this.remoteAvailable) + .opacity(this.remoteAvailable ? 1 : 0.45) + .onClick(() => { + this.onRefresh(); + }) + } + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + UnsupportedState() { + Column({ space: 10 }) { + SymbolGlyph($r('sys.symbol.doc')) + .fontSize(38) + .fontColor([MUTED]) + Text(RemoteI18n.t('filePreview.unsupported')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.metadataDetail()) + .fontSize(13) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + Button(RemoteI18n.t('chat.download')) + .height(42) + .fontSize(14) + .fontColor(CARD) + .backgroundColor(INK) + .borderRadius(21) + .enabled(this.remoteAvailable) + .opacity(this.remoteAvailable ? 1 : 0.45) + .onClick(() => { + this.onDownload(this.state.target.rawReference || this.state.target.remotePath); + }) + } + .width('100%') + .layoutWeight(1) + .padding({ left: 32, right: 32 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + TextPreview() { + Column() { + if (this.state.truncated) { + Text(RemoteI18n.f('filePreview.truncated', RemoteUiState.formatBytes(this.state.loadedBytes))) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + .padding({ left: 14, right: 14, top: 7, bottom: 7 }) + .backgroundColor(SOFT) + .border({ width: { bottom: 1 }, color: LINE }) + } + Scroll(this.textScroller) { + Text() { + ForEach(this.syntaxTokens(), (token: CodeSyntaxToken) => { + Span(token.text) + .fontColor(this.syntaxTokenColor(token.kind)) + .textBackgroundStyle({ + color: this.isTargetLine(token.lineNumber) ? CODE_TARGET_BG : '#00000000' + }) + }, (token: CodeSyntaxToken) => token.id) + } + .fontSize(12) + .lineHeight(19) + .fontColor(INK) + .fontFamily('monospace') + .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) + .padding({ left: 14, right: 20, top: 14, bottom: 24 }) + .constraintSize({ minWidth: '100%' }) + } + .scrollable(ScrollDirection.FREE) + .scrollBar(BarState.Auto) + .layoutWeight(1) + .width('100%') + .onAppear(() => { + this.restoreTextScroll(); + }) + .onDidScroll((xOffset: number, yOffset: number, _scrollState: ScrollState) => { + this.state.recordScroll(xOffset, yOffset); + }) + } + .layoutWeight(1) + .width('100%') + .backgroundColor(SOFT) + } + + @Builder + MarkdownPreview() { + Scroll(this.markdownScroller) { + MarkdownContent({ + text: this.state.textContent, + onCopyText: (_text: string) => {}, + onOpenLink: (reference: string, label: string) => { + this.onOpenLink(reference, label); + } + }) + .padding({ left: 20, right: 20, top: 16, bottom: 28 }) + } + .scrollable(ScrollDirection.Vertical) + .scrollBar(BarState.Auto) + .layoutWeight(1) + .width('100%') + .onAppear(() => { + this.restoreMarkdownScroll(); + }) + .onDidScroll((xOffset: number, yOffset: number, _scrollState: ScrollState) => { + this.state.recordScroll(xOffset, yOffset); + }) + } + + @Builder + ImagePreview() { + Stack({ alignContent: Alignment.Center }) { + Image(`data:${this.state.mimeType};base64,${this.state.contentBase64}`) + .width('100%') + .height('100%') + .objectFit(this.isImageOriginalSize() ? ImageFit.None : ImageFit.Contain) + .onClick(() => { + this.toggleImageSize(); + }) + .onError((_error: ImageError) => { + this.imageOriginalSizeTarget = ''; + this.state.phase = FilePreviewPhase.Error; + this.state.errorText = RemoteI18n.t('filePreview.imageDecodeFailed'); + this.state.errorRetryable = true; + }) + } + .layoutWeight(1) + .width('100%') + .padding(18) + .backgroundColor(SOFT) + } + + @Builder + EmptyState() { + Column() { + } + .layoutWeight(1) + .width('100%') + } + + private canUseRemoteAction(): boolean { + return this.remoteAvailable && this.state.visible && this.state.phase !== FilePreviewPhase.Loading && + (this.state.phase !== FilePreviewPhase.Error || this.state.errorRetryable) && + this.state.target.isValid(); + } + + private canDownload(): boolean { + return this.remoteAvailable && this.state.visible && !this.isDownloadingTarget() && this.state.target.isValid(); + } + + private isDownloadingTarget(): boolean { + return this.downloadPath.length > 0 && this.downloadMatchesTarget() && this.downloadedPath.length === 0; + } + + private downloadMatchesTarget(): boolean { + const target = this.state.target.rawReference || this.state.target.remotePath; + return target.length > 0 && (target === this.downloadPath || target === this.downloadedPath); + } + + private isImageReady(): boolean { + return this.state.phase === FilePreviewPhase.Ready && + this.state.rendererKind === FilePreviewRendererKind.Image; + } + + private isImageOriginalSize(): boolean { + return this.imageOriginalSizeTarget.length > 0 && + this.imageOriginalSizeTarget === this.state.target.remotePath; + } + + private toggleImageSize(): void { + this.imageOriginalSizeTarget = this.isImageOriginalSize() ? '' : this.state.target.remotePath; + } + + private headerDetail(): string { + if (!this.remoteAvailable) { + return RemoteI18n.t('filePreview.offline'); + } + if (this.state.phase === FilePreviewPhase.Loading) { + return RemoteI18n.t('filePreview.loading'); + } + return this.metadataDetail(); + } + + private metadataDetail(): string { + const size = this.state.fileSize > 0 ? RemoteUiState.formatBytes(this.state.fileSize) : ''; + if (this.state.mimeType.length > 0 && size.length > 0) { + return `${this.state.mimeType} · ${size}`; + } + return this.state.mimeType || size || this.state.target.remotePath; + } + + private syntaxTokens(): CodeSyntaxToken[] { + return this.syntaxHighlightCache.tokensFor( + this.state.textContent, + this.state.fileName || this.state.target.remotePath + ); + } + + private syntaxTokenColor(kind: CodeSyntaxTokenKind): ResourceColor { + if (kind === CodeSyntaxTokenKind.LineNumber) { + return CODE_LINE_NUMBER; + } + if (kind === CodeSyntaxTokenKind.Keyword) { + return CODE_KEYWORD; + } + if (kind === CodeSyntaxTokenKind.String) { + return CODE_STRING; + } + if (kind === CodeSyntaxTokenKind.Number) { + return CODE_NUMBER; + } + if (kind === CodeSyntaxTokenKind.Comment) { + return CODE_COMMENT; + } + if (kind === CodeSyntaxTokenKind.Function) { + return CODE_FUNCTION; + } + if (kind === CodeSyntaxTokenKind.Type) { + return CODE_TYPE; + } + if (kind === CodeSyntaxTokenKind.Constant) { + return CODE_CONSTANT; + } + if (kind === CodeSyntaxTokenKind.Property) { + return CODE_PROPERTY; + } + return INK; + } + + private isTargetLine(lineNumber: number): boolean { + if (lineNumber <= 0 || this.state.target.lineStart <= 0) { + return false; + } + const end = this.state.target.lineEnd > 0 ? this.state.target.lineEnd : this.state.target.lineStart; + return lineNumber >= this.state.target.lineStart && lineNumber <= end; + } + + private restoreTextScroll(): void { + this.clearRestoreScrollTimer(); + this.restoreScrollTimerId = setTimeout(() => { + this.restoreScrollTimerId = 0; + const xOffset = this.state.initialScrollX(); + const yOffset = this.state.initialScrollY(); + this.textScroller.scrollTo({ xOffset, yOffset, animation: false }); + this.state.recordScroll(xOffset, yOffset); + }, 30); + } + + private restoreMarkdownScroll(): void { + this.clearRestoreScrollTimer(); + this.restoreScrollTimerId = setTimeout(() => { + this.restoreScrollTimerId = 0; + const xOffset = this.state.initialScrollX(); + const yOffset = this.state.hasRecordedScroll ? this.state.initialScrollY() : 0; + this.markdownScroller.scrollTo({ xOffset, yOffset, animation: false }); + this.state.recordScroll(xOffset, yOffset); + }, 30); + } + + private clearRestoreScrollTimer(): void { + if (this.restoreScrollTimerId !== 0) { + clearTimeout(this.restoreScrollTimerId); + this.restoreScrollTimerId = 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets index 0eb34698f1..06f477b898 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets @@ -1,56 +1,85 @@ -import { CARD, INK, LINE, MUTED } from './Theme'; +import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; @Component export struct FileReferenceCard { @Prop path: string = ''; @Prop label: string = ''; @Prop status: string = ''; + @Prop previewLabel: string = ''; @Prop buttonLabel: string = ''; @Prop disabled: boolean = false; + @Prop selected: boolean = false; + @Prop previewLoading: boolean = false; + onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; onDownload: (path: string) => void = (_path: string) => {}; build() { Row({ space: 10 }) { - Text('▤') + Row({ space: 10 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.previewLoading) { + LoadingProgress() + .width(17) + .height(17) + .color(FILE_LINK) + } else { + Text('▤') + .fontSize(16) + .fontColor(INK) + } + } .width(34) .height(34) - .fontSize(16) - .fontColor(INK) - .textAlign(TextAlign.Center) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) - Column({ space: 3 }) { - Text(this.label) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.status) - .fontSize(11) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) + Column({ space: 3 }) { + Text(this.label) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.status) + .fontSize(11) + .fontColor(FILE_LINK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) } .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Text(this.buttonLabel) - .fontSize(12) - .fontColor(this.disabled ? MUTED : INK) - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .backgroundColor('#F0EFEB') - .borderRadius(14) - .border({ width: 1, color: LINE }) - .onClick(() => { - if (!this.disabled) { - this.onDownload(this.path); - } - }) + .height(44) + .accessibilityText(`${this.previewLabel} ${this.label}`) + .onClick(() => { + this.onPreview(this.path, this.label); + }) + Stack({ alignContent: Alignment.Center }) { + if (this.disabled) { + LoadingProgress() + .width(18) + .height(18) + .color(MUTED) + } else { + SymbolGlyph($r('sys.symbol.arrow_down_to_line')) + .fontSize(19) + .fontColor([INK]) + } + } + .width(44) + .height(44) + .accessibilityText(this.buttonLabel) + .opacity(this.disabled ? 0.55 : 1) + .onClick(() => { + if (!this.disabled) { + this.onDownload(this.path); + } + }) } .width('100%') .padding(12) - .backgroundColor(CARD) + .backgroundColor(this.selected ? SOFT : CARD) .borderRadius(14) - .border({ width: 1, color: LINE }) + .border({ width: 1, color: this.selected ? FILE_LINK : LINE }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index 06e3cfb177..91a4b120b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,65 +1,103 @@ -import { CARD, INK, PAGE_BG } from './Theme'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { SidebarToggleButton } from './SidebarToggleButton'; -@Component +@ComponentV2 export struct GeneralChatHeader { - onOpenSidebar: () => void = () => {}; - @Prop showActions: boolean = false; - @Prop showSidebarButton: boolean = true; - onNewSession: () => void = () => {}; - onOpenActions: () => void = () => {}; + @Param title: string = ''; + @Param showActions: boolean = false; + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onBack: () => void = () => {}; + @Event onOpenActions: () => void = () => {}; build() { - Row() { - if (this.showSidebarButton) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.gpt_home_menu_glyph')) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => { - this.onOpenSidebar(); - }) - } - Text('BitFun') + Row({ space: 8 }) { + this.LeadingControl() + + Text(this.title || 'BitFun') .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .height(48) - .padding({ left: this.showSidebarButton ? 18 : 0, right: 18 }) - .margin({ left: this.showSidebarButton ? 12 : 0 }) - Blank() - if (this.showActions) { - Row() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_edit')) - .width(24).height(24).objectFit(ImageFit.Contain) - } - .width(44).height(48) - .onClick(() => this.onNewSession()) - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(25).height(8).objectFit(ImageFit.Contain) - } - .width(44).height(48) - .onClick(() => this.onOpenActions()) - } - .width(90).height(48) - .padding({ left: 1, right: 1 }) - .backgroundColor('#FFFFFF') - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - } + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + + this.TrailingControl() } .width('100%') - .height(52) + .height(64) .alignItems(VerticalAlign.Center) - .padding({ left: 24, right: 24 }) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) .backgroundColor(PAGE_BG) } + + @Builder + private LeadingControl() { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.showBackButton) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(48) + .height(48) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => { + this.onBack(); + }) + } else if (this.showSidebarButton) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.line_3_horizontal')) + .fontSize(22) + .fontColor([INK]) + } + .width(48) + .height(48) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .onClick(() => { + this.onOpenSidebar(); + }) + } else { + Blank().width(48).height(48) + } + } + + @Builder + private TrailingControl() { + if (this.showActions) { + Stack({ alignContent: Alignment.Center }) { + Text('•••') + .fontSize(13) + .fontColor(INK) + } + .width(48) + .height(48) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .onClick(() => { + this.onOpenActions(); + }) + } else { + Blank().width(48).height(48) + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 24e7d708a6..486aad6a67 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -1,15 +1,22 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { MarkdownParser, ParsedMarkdownBlock, ParsedMarkdownInline, ParsedMarkdownListItem } from '../../services/MarkdownParser'; -import { CARD, INK, LINE, MUTED } from './Theme'; +import { + MarkdownParseCache, + ParsedMarkdownBlock, + ParsedMarkdownInline, + ParsedMarkdownListItem +} from '../../services/MarkdownParser'; +import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; @Component export struct MarkdownContent { + private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); @Prop text: string = ''; onCopyText: (text: string) => void = (_text: string) => {}; + onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { Column({ space: 5 }) { - ForEach(MarkdownParser.parse(this.text), (block: ParsedMarkdownBlock) => { + ForEach(this.parseCache.blocksFor(this.text), (block: ParsedMarkdownBlock) => { this.MarkdownBlockView(block) }, (block: ParsedMarkdownBlock) => block.id) } @@ -44,7 +51,7 @@ export struct MarkdownContent { } @Builder - InlineText(inlines: ParsedMarkdownInline[], fontSize: number, lineHeight: number, color: string, bold: boolean) { + InlineText(inlines: ParsedMarkdownInline[], fontSize: number, lineHeight: number, color: ResourceColor, bold: boolean) { Text() { ForEach(inlines, (inline: ParsedMarkdownInline) => { this.InlineSpan(inline, color, bold) @@ -57,7 +64,7 @@ export struct MarkdownContent { } @Builder - InlineSpan(inline: ParsedMarkdownInline, color: string, bold: boolean) { + InlineSpan(inline: ParsedMarkdownInline, color: ResourceColor, bold: boolean) { if (inline.type === 'strong') { Span(inline.text) .fontWeight(FontWeight.Bold) @@ -75,7 +82,11 @@ export struct MarkdownContent { } else if (inline.type === 'link') { Span(inline.text) .fontWeight(bold ? FontWeight.Bold : FontWeight.Regular) - .fontColor('#1D64C8') + .fontColor(FILE_LINK) + .decoration({ type: TextDecorationType.Underline, color: FILE_LINK }) + .onClick(() => { + this.onOpenLink(inline.url, inline.text); + }) } else { Span(inline.text) .fontWeight(bold ? FontWeight.Bold : FontWeight.Regular) @@ -120,7 +131,7 @@ export struct MarkdownContent { .scrollBar(BarState.Off) .width('100%') .padding({ left: 9, right: 9, top: 8, bottom: 8 }) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } @@ -155,7 +166,7 @@ export struct MarkdownContent { } .width('100%') .padding({ left: 9, right: 9, top: 8, bottom: 8 }) - .backgroundColor('#F3F2EE') + .backgroundColor(SOFT) .borderRadius(12) .border({ width: 1, color: LINE }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 96f47f6214..3c92d19156 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -1,11 +1,24 @@ +import { KeyboardAvoidMode } from '@kit.ArkUI'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/GeneralChatConfigStore'; +import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; @Component export struct ModelServiceSettingsPanel { + private readonly contentScroller: Scroller = new Scroller(); + private focusScrollTimerId: number = 0; + private blurResetTimerId: number = 0; + private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; @Prop apiUrl: string = ''; @Prop modelName: string = ''; @Prop hasApiKey: boolean = false; + @Prop modelCatalog: RemoteModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Prop selectedModelId: string = ''; onClose: () => void = () => {}; onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; onTest: ( @@ -38,8 +51,12 @@ export struct ModelServiceSettingsPanel { @State isTesting: boolean = false; @State feedbackText: string = ''; @State feedbackIsError: boolean = false; + @State focusedFieldKind: string = ''; + @State showLocalEditor: boolean = false; aboutToAppear(): void { + this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); + this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); this.draftApiUrl = this.apiUrl; this.draftModelName = this.modelName; this.draftApiKey = ''; @@ -47,6 +64,13 @@ export struct ModelServiceSettingsPanel { this.isTesting = false; this.feedbackText = ''; this.feedbackIsError = false; + this.showLocalEditor = false; + } + + aboutToDisappear(): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.getUIContext().setKeyboardAvoidMode(this.previousKeyboardAvoidMode); } build() { @@ -63,70 +87,11 @@ export struct ModelServiceSettingsPanel { Column({ space: 0 }) { this.Header() - Scroll() { - Column({ space: 20 }) { - this.ConfigField( - RemoteI18n.t('settings.modelService.apiUrl'), - RemoteI18n.t('settings.modelService.apiUrlPlaceholder'), - 'url' - ) - this.ApiKeyField() - this.ConfigField( - RemoteI18n.t('settings.modelService.modelName'), - RemoteI18n.t('settings.modelService.modelPlaceholder'), - 'model' - ) - Row({ space: 12 }) { - Button(this.isTesting ? RemoteI18n.t('common.loading') : RemoteI18n.t('settings.modelService.testConnection')) - .layoutWeight(1) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .backgroundColor(this.isTesting ? MUTED : SOFT) - .borderRadius(25) - .enabled(!this.isSaving && !this.isTesting && this.hasTestableApiKey()) - .onClick(() => { - this.testConnection(); - }) - Button(this.isSaving ? RemoteI18n.t('common.loading') : RemoteI18n.t('common.save')) - .layoutWeight(1) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(this.isSaving ? MUTED : ACCENT) - .borderRadius(25) - .enabled(!this.isSaving && !this.isTesting) - .onClick(() => { - this.save(); - }) - } - .width('100%') - - if (!this.hasTestableApiKey()) { - Text(RemoteI18n.t('settings.modelService.testNeedsKey')) - .fontSize(12) - .lineHeight(18) - .fontColor(SUBTLE) - .width('100%') - } - - if (this.feedbackText.length > 0) { - Text(this.feedbackText) - .fontSize(13) - .lineHeight(19) - .fontColor(this.feedbackIsError ? RED : GREEN) - .width('100%') - } - } - .width('100%') - .padding({ left: 22, right: 22, top: 18, bottom: 30 }) - .justifyContent(FlexAlign.Start) + if (this.showLocalEditor) { + this.LocalEditor() + } else { + this.ModelOverview() } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) } .width('100%') .height('78%') @@ -140,16 +105,35 @@ export struct ModelServiceSettingsPanel { @Builder Header() { Row() { - Text(RemoteI18n.t('settings.modelService.title')) + if (this.showLocalEditor) { + Button() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(20) + .fontColor([INK]) + } + .width(42) + .height(42) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(SOFT) + .margin({ right: 12 }) + .onClick(() => { + if (!this.isSaving && !this.isTesting) { + this.showLocalEditor = false; + this.resetEditorScroll(); + } + }) + } + Text(RemoteI18n.t(this.showLocalEditor ? + 'settings.modelService.localTitle' : 'settings.modelService.manageTitle')) .fontSize(21) .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() Button() { - Image($r('app.media.settings_close_x')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(20) + .fontColor([INK]) } .width(42) .height(42) @@ -169,6 +153,242 @@ export struct ModelServiceSettingsPanel { .alignItems(VerticalAlign.Center) } + @Builder + ModelOverview() { + Scroll() { + Column({ space: 20 }) { + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.currentModel'), '') + this.CurrentModelRow() + } + .width('100%') + + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.accountModels'), '') + this.AccountModelsSummaryRow() + } + .width('100%') + + Column({ space: 8 }) { + this.SectionHeader(RemoteI18n.t('settings.modelService.localModel'), '') + this.LocalModelRow() + } + .width('100%') + } + .width('100%') + .padding({ left: 22, right: 22, top: 18, bottom: 30 }) + .justifyContent(FlexAlign.Start) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + + @Builder + LocalEditor() { + Scroll(this.contentScroller) { + Column({ space: 20 }) { + this.ConfigField( + RemoteI18n.t('settings.modelService.apiUrl'), + RemoteI18n.t('settings.modelService.apiUrlPlaceholder'), + 'url' + ) + this.ApiKeyField() + this.ConfigField( + RemoteI18n.t('settings.modelService.modelName'), + RemoteI18n.t('settings.modelService.modelPlaceholder'), + 'model' + ) + Row({ space: 12 }) { + Button(this.isTesting ? RemoteI18n.t('common.loading') : RemoteI18n.t('settings.modelService.testConnection')) + .layoutWeight(1) + .height(50) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .backgroundColor(this.isTesting ? MUTED : SOFT) + .borderRadius(25) + .enabled(!this.isSaving && !this.isTesting && this.hasTestableApiKey()) + .onClick(() => { + this.testConnection(); + }) + Button(this.isSaving ? RemoteI18n.t('common.loading') : RemoteI18n.t('common.save')) + .layoutWeight(1) + .height(50) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(this.isSaving ? MUTED : ACCENT) + .borderRadius(25) + .enabled(!this.isSaving && !this.isTesting) + .onClick(() => { + this.save(); + }) + } + .width('100%') + + if (!this.hasTestableApiKey()) { + Text(RemoteI18n.t('settings.modelService.testNeedsKey')) + .fontSize(12) + .lineHeight(18) + .fontColor(SUBTLE) + .width('100%') + } + + if (this.feedbackText.length > 0) { + Text(this.feedbackText) + .fontSize(13) + .lineHeight(19) + .fontColor(this.feedbackIsError ? RED : GREEN) + .width('100%') + } + } + .width('100%') + .padding({ + left: 22, + right: 22, + top: 18, + bottom: this.focusedFieldKind.length > 0 ? 360 : 30 + }) + .justifyContent(FlexAlign.Start) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + + @Builder + SectionHeader(title: string, detail: string) { + Row() { + Text(title) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Blank() + if (detail.length > 0) { + Text(detail) + .fontSize(12) + .fontColor(SUBTLE) + } + } + .width('100%') + .padding({ left: 4, right: 4 }) + } + + @Builder + CurrentModelRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(23) + .fontColor([this.currentModel() ? INK : MUTED]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(this.currentModelLabel()) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.currentModel()) { + Text(this.modelSourceLabel(this.currentModel())) + .fontSize(12) + .fontColor(MUTED) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height(68) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + } + + @Builder + AccountModelsSummaryRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.cloud')) + .fontSize(21) + .fontColor([this.accountModels().length > 0 ? MUTED : SUBTLE]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.modelService.accountModelSummary')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountModels().length > 0 ? + RemoteI18n.f('settings.modelService.syncedCount', String(this.accountModels().length)) : + RemoteI18n.t('settings.modelService.accountEmpty')) + .fontSize(12) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (this.isAccountModelSelected()) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(18) + .fontColor([INK]) + } + } + .width('100%') + .height(62) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + } + + @Builder + LocalModelRow() { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) + .fontSize(21) + .fontColor([MUTED]) + .width(28) + .height(28) + Column({ space: 3 }) { + Text(this.hasCompleteLocalModel() ? this.modelName : RemoteI18n.t('settings.modelService.notConfigured')) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.hasCompleteLocalModel()) { + Text(RemoteI18n.t('settings.modelService.localSource')) + .fontSize(12) + .fontColor(MUTED) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (this.isLocalModelSelected()) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(18) + .fontColor([INK]) + } + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) + } + .width('100%') + .height(62) + .padding({ left: 16, right: 16 }) + .backgroundColor(SOFT) + .borderRadius(8) + .onClick(() => { + this.showLocalEditor = true; + this.resetEditorDraft(); + }) + } + @Builder ConfigField(label: string, placeholder: string, kind: string) { Column({ space: 8 }) { @@ -189,6 +409,12 @@ export struct ModelServiceSettingsPanel { .backgroundColor(SOFT) .borderRadius(8) .padding({ left: 14, right: 14 }) + .onFocus(() => { + this.scrollFocusedFieldIntoView(kind); + }) + .onBlur(() => { + this.scheduleFocusedFieldReset(); + }) .onChange((value: string) => { if (kind === 'url') { this.draftApiUrl = value; @@ -232,6 +458,12 @@ export struct ModelServiceSettingsPanel { .backgroundColor(SOFT) .borderRadius(8) .padding({ left: 14, right: 10 }) + .onFocus(() => { + this.scrollFocusedFieldIntoView('key'); + }) + .onBlur(() => { + this.scheduleFocusedFieldReset(); + }) .onChange((value: string) => { this.draftApiKey = value; if (value.length > 0) { @@ -258,6 +490,78 @@ export struct ModelServiceSettingsPanel { .alignItems(HorizontalAlign.Start) } + private accountModels(): RemoteModelConfig[] { + return this.modelCatalog.models.filter((model: RemoteModelConfig): boolean => { + return model.enabled && model.id.startsWith('cloud:'); + }); + } + + private currentModel(): RemoteModelConfig | undefined { + const candidates = [ + this.selectedModelId, + this.modelCatalog.session_model_id || '', + this.modelCatalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + const model = this.modelCatalog.models.find((item: RemoteModelConfig): boolean => { + return item.id === modelId && item.enabled; + }); + if (model) { + return model; + } + } + return undefined; + } + + private currentModelLabel(): string { + const model = this.currentModel(); + return model ? this.modelLabel(model) : RemoteI18n.t('settings.modelService.notConfigured'); + } + + private modelLabel(model: RemoteModelConfig): string { + return model.model_name || model.name || model.id; + } + + private modelSourceLabel(model?: RemoteModelConfig): string { + if (!model) { + return ''; + } + return model.id === GENERAL_CHAT_LOCAL_MODEL_ID ? + RemoteI18n.t('settings.modelService.localSource') : + RemoteI18n.t('settings.modelService.accountSource'); + } + + private isAccountModelSelected(): boolean { + const current = this.currentModel(); + return current !== undefined && current.id.startsWith('cloud:'); + } + + private isLocalModelSelected(): boolean { + return this.currentModel()?.id === GENERAL_CHAT_LOCAL_MODEL_ID; + } + + private hasCompleteLocalModel(): boolean { + return this.apiUrl.trim().length > 0 && this.modelName.trim().length > 0 && this.hasApiKey; + } + + private resetEditorDraft(): void { + this.draftApiUrl = this.apiUrl; + this.draftModelName = this.modelName; + this.draftApiKey = ''; + this.clearApiKey = false; + this.isTesting = false; + this.clearFeedback(); + this.resetEditorScroll(); + } + + private resetEditorScroll(): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.focusedFieldKind = ''; + this.contentScroller.scrollTo({ xOffset: 0, yOffset: 0, animation: false }); + } + private apiKeyPlaceholder(): string { return this.hasApiKey && !this.clearApiKey ? RemoteI18n.t('settings.modelService.apiKeyKeepPlaceholder') : @@ -287,7 +591,8 @@ export struct ModelServiceSettingsPanel { this.draftModelName.trim(), !this.clearApiKey && (this.draftApiKey.trim().length > 0 || this.hasApiKey) ); - this.onClose(); + this.showLocalEditor = false; + this.resetEditorScroll(); } private async testConnection(): Promise { @@ -320,4 +625,38 @@ export struct ModelServiceSettingsPanel { this.feedbackText = ''; this.feedbackIsError = false; } + + private scrollFocusedFieldIntoView(kind: string): void { + this.clearFocusScrollTimer(); + this.clearBlurResetTimer(); + this.focusedFieldKind = kind; + const yOffset = kind === 'key' ? 118 : (kind === 'model' ? 244 : 0); + this.focusScrollTimerId = setTimeout(() => { + this.contentScroller.scrollTo({ xOffset: 0, yOffset, animation: true }); + this.focusScrollTimerId = 0; + }, 280); + } + + private scheduleFocusedFieldReset(): void { + this.clearBlurResetTimer(); + this.blurResetTimerId = setTimeout(() => { + this.focusedFieldKind = ''; + this.contentScroller.scrollTo({ xOffset: 0, yOffset: 0, animation: true }); + this.blurResetTimerId = 0; + }, 180); + } + + private clearFocusScrollTimer(): void { + if (this.focusScrollTimerId !== 0) { + clearTimeout(this.focusScrollTimerId); + this.focusScrollTimerId = 0; + } + } + + private clearBlurResetTimer(): void { + if (this.blurResetTimerId !== 0) { + clearTimeout(this.blurResetTimerId); + this.blurResetTimerId = 0; + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets index a1ae208a45..6ff4246ac9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets @@ -1,6 +1,6 @@ import { AssistantEntry, RecentWorkspaceEntry } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED, RED, SUBTLE } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; @ComponentV2 export struct RemoteActionsSheet { @@ -31,6 +31,7 @@ export struct RemoteActionsSheet { @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; @Event onAddConnection: () => void = () => {}; @Event onOpenSettings: () => void = () => {}; + @Event onOpenViewSettings: () => void = () => {}; aboutToAppear(): void { this.selectedSortMode = this.sortMode; @@ -67,14 +68,9 @@ export struct RemoteActionsSheet { private RemoteActionRows() { Column({ space: 0 }) { this.SectionTitle(RemoteI18n.t('remote.menu.organize')) - this.IconRow('remote_actions_folder', RemoteI18n.t('remote.menu.byProject'), 'project', () => { - this.selectSortMode('project'); - }) - this.IconRow('remote_actions_clock', RemoteI18n.t('remote.menu.byTime'), 'time', () => { - this.selectSortMode('time'); - }) - this.IconRow('remote_actions_chat', RemoteI18n.t('remote.menu.chatFirst'), 'chat', () => { - this.selectSortMode('chat'); + this.IconRow('remote_actions_settings', RemoteI18n.t('viewSettings.title'), '', () => { + this.onDismiss(); + this.onOpenViewSettings(); }) Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) this.SectionTitle(RemoteI18n.t('remote.menu.manage')) @@ -102,7 +98,7 @@ export struct RemoteActionsSheet { Text(title) .fontSize(13) .fontWeight(FontWeight.Medium) - .fontColor('#8E8E93') + .fontColor(MUTED) .width('100%') .height(28) .padding({ left: 8 }) @@ -113,10 +109,11 @@ export struct RemoteActionsSheet { private IconRow(icon: string, label: string, sortMode: string, action: () => void) { Row({ space: 10 }) { if (sortMode.length > 0 && this.selectedSortMode === sortMode) { - Image($r('app.media.remote_actions_check')) + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(18) + .fontColor([INK]) .width(20) .height(20) - .objectFit(ImageFit.Contain) } else { Blank().width(20) } @@ -132,24 +129,24 @@ export struct RemoteActionsSheet { .height(48) .padding({ left: 8, right: 8 }) .borderRadius(10) - .backgroundColor(sortMode.length > 0 && this.selectedSortMode === sortMode ? '#F3F3F3' : '#00000000') + .backgroundColor(sortMode.length > 0 && this.selectedSortMode === sortMode ? SOFT : '#00000000') .onClick(action) } @Builder private ActionIcon(icon: string) { if (icon === 'remote_actions_folder') { - Image($r('app.media.remote_actions_folder')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_clock') { - Image($r('app.media.remote_actions_clock')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.clock')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_chat') { - Image($r('app.media.remote_actions_chat')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.message')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_cloud') { - Image($r('app.media.remote_actions_cloud')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.cloud')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else if (icon === 'remote_actions_link') { - Image($r('app.media.remote_actions_link')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.link')).fontSize(20).fontColor([MUTED]).width(23).height(23) } else { - Image($r('app.media.remote_actions_settings')).width(23).height(23).objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.gearshape')).fontSize(20).fontColor([MUTED]).width(23).height(23) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets index d04e29ba20..9bfee1a868 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets @@ -1,5 +1,5 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, SUBTLE } from './Theme'; +import { CARD, INK, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SUBTLE } from './Theme'; @ComponentV2 export struct RemoteBottomBar { @@ -12,10 +12,9 @@ export struct RemoteBottomBar { build() { Row({ space: 12 }) { Row({ space: 8 }) { - Image($r('app.media.remote_ref_search_reference')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(20) + .fontColor([INK]) TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.query }) .layoutWeight(1) .height(48) @@ -42,20 +41,19 @@ export struct RemoteBottomBar { Button() { Row({ space: 9 }) { - Image($r('app.media.remote_ref_new_chat')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(20) + .fontColor([PRIMARY_ACTION_TEXT]) Text(RemoteI18n.t('remote.newChat')) .fontSize(16) .fontWeight(FontWeight.Medium) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) } } .width(118) .height(48) .padding(0) - .backgroundColor(ACCENT) + .backgroundColor(PRIMARY_ACTION) .borderRadius(24) .shadow({ radius: 20, color: '#18000000', offsetY: 7 }) .enabled(!this.isBusy) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 77866b0edd..adfd6853de 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ConversationUiModel, ConversationUiModelCatalog, ConversationUiSession } from './ConversationUiModels'; -import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { ConversationUiSession } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct RemoteChatHeader { @@ -12,21 +13,13 @@ export struct RemoteChatHeader { }; @Param workspaceBranch: string = ''; @Param desktopName: string = ''; - @Param canStop: boolean = false; - @Param modelCatalog: ConversationUiModelCatalog = { - version: 0, - models: [], - default_models: {} - }; - @Param selectedModelId: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarRestoreButton: boolean = false; @Event onBack: () => void = () => {}; - @Event onNewSession: () => void = () => {}; - @Event onStop: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onOpenActions: () => void = () => {}; @Event onRenameSession: (title: string) => void = (_title: string) => {}; - @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Local showTitleEditor: boolean = false; - @Local showModelSelector: boolean = false; @Local renameTitle: string = ''; build() { @@ -35,9 +28,6 @@ export struct RemoteChatHeader { if (this.showTitleEditor) { this.TitleEditor() } - if (this.showModelSelector) { - this.ModelSelector() - } } .width('100%') .backgroundColor(PAGE_BG) @@ -45,29 +35,14 @@ export struct RemoteChatHeader { @Builder HeaderRow() { - Row({ space: 12 }) { - if (this.showBackButton) { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => { - this.onBack(); - }) - } + Row({ space: 8 }) { + this.LeadingControl() Column({ space: 3 }) { Text(this.activeSession.title || RemoteI18n.t('chat.remoteSession')) .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor(INK) - .textAlign(TextAlign.Start) + .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .onClick(() => { @@ -78,45 +53,30 @@ export struct RemoteChatHeader { Text(this.headerContextTitle()) .fontSize(14) .fontColor(MUTED) - .textAlign(TextAlign.Start) + .textAlign(TextAlign.Center) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') - .justifyContent(FlexAlign.Start) + .justifyContent(FlexAlign.Center) } .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Row() { - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_edit')) - .width(24) - .height(24) - .objectFit(ImageFit.Contain) - } - .width(44) - .height(48) - .onClick(() => { - this.onNewSession(); - }) - Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(25) - .height(8) - .objectFit(ImageFit.Contain) - } - .width(44) - .height(48) - .onClick(() => { - this.showModelSelector = !this.showModelSelector; - }) + .alignItems(HorizontalAlign.Center) + Stack({ alignContent: Alignment.Center }) { + Text('•••') + .fontSize(13) + .fontColor(INK) } - .width(90) + .width(48) .height(48) - .padding({ left: 1, right: 1 }) .backgroundColor(CARD) .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) + .border({ width: 1, color: LINE }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .onClick(() => { + this.showTitleEditor = false; + this.onOpenActions(); + }) } .width('100%') .alignItems(VerticalAlign.Center) @@ -124,6 +84,34 @@ export struct RemoteChatHeader { .backgroundColor(PAGE_BG) } + @Builder + private LeadingControl() { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.showBackButton) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(48) + .height(48) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => { + this.onBack(); + }) + } else { + Blank().width(48).height(48) + } + } + @Builder TitleEditor() { Row({ space: 8 }) { @@ -143,9 +131,9 @@ export struct RemoteChatHeader { .width(52) .height(42) .fontSize(13) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .textAlign(TextAlign.Center) - .backgroundColor(this.renameTitle.trim().length > 0 ? ACCENT : '#EDEBE6') + .backgroundColor(this.renameTitle.trim().length > 0 ? ACCENT : SOFT) .borderRadius(14) .onClick(() => { if (this.renameTitle.trim().length > 0) { @@ -159,7 +147,7 @@ export struct RemoteChatHeader { .fontSize(13) .fontColor(INK) .textAlign(TextAlign.Center) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => { this.showTitleEditor = false; @@ -170,96 +158,6 @@ export struct RemoteChatHeader { .backgroundColor(PAGE_BG) } - @Builder - ModelSelector() { - Column({ space: 8 }) { - Row() { - Text(`${RemoteI18n.t('chat.selectModel')} · ${this.displaySelectedModel()}`) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(RemoteI18n.t('common.close')) - .fontSize(12) - .fontColor(MUTED) - .onClick(() => { - this.showModelSelector = false; - }) - } - .width('100%') - if (this.canStop) { - Row() { - Text(RemoteI18n.t('chat.stop')) - .fontSize(14) - .fontColor(INK) - Blank() - Text('■') - .fontSize(12) - .fontColor(INK) - } - .width('100%') - .height(42) - .padding({ left: 12, right: 12 }) - .backgroundColor('#F0EFEB') - .borderRadius(12) - .onClick(() => { - this.showModelSelector = false; - this.onStop(); - }) - } - List({ space: 8 }) { - ForEach(this.enabledModels(), (model: ConversationUiModel) => { - ListItem() { - this.ModelRow(model) - } - }, (model: ConversationUiModel) => model.id) - } - .width('100%') - .height(this.modelListHeight()) - .scrollBar(BarState.Auto) - .edgeEffect(EdgeEffect.Spring) - .divider(null) - } - .width('100%') - .padding({ left: 18, right: 18, top: 10, bottom: 12 }) - .backgroundColor(PAGE_BG) - } - - @Builder - ModelRow(model: ConversationUiModel) { - Row({ space: 10 }) { - Text(this.selectedModelId === model.id ? '●' : '○') - .fontSize(12) - .fontColor(this.selectedModelId === model.id ? GREEN : MUTED) - .width(18) - .textAlign(TextAlign.Center) - Column({ space: 2 }) { - Text(this.primaryModelLabel(model)) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.secondaryModelLabel(model)) - .fontSize(11) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .padding({ left: 12, right: 12, top: 10, bottom: 10 }) - .backgroundColor(this.selectedModelId === model.id ? '#F0EFEB' : CARD) - .borderRadius(14) - .border({ width: 1, color: this.selectedModelId === model.id ? '#D8D4CA' : LINE }) - .onClick(() => { - this.showModelSelector = false; - this.onSelectModel(model.id); - }) - } - private headerContextTitle(): string { if (this.desktopName.length > 0) { return this.desktopName; @@ -268,86 +166,4 @@ export struct RemoteChatHeader { return this.workspaceBranch.length > 0 ? `${brand} · ${this.workspaceBranch}` : brand; } - private enabledModels(): ConversationUiModel[] { - return this.modelCatalog.models.filter((model: ConversationUiModel) => model.enabled); - } - - private modelListHeight(): number { - const rowHeight = 58; - const rowGap = 8; - const visibleRows = Math.min(this.enabledModels().length, 5); - if (visibleRows <= 0) { - return 0; - } - return visibleRows * rowHeight + Math.max(0, visibleRows - 1) * rowGap; - } - - private displaySelectedModel(): string { - const selected = this.selectedModel(); - if (selected) { - return this.primaryModelLabel(selected); - } - return RemoteI18n.t('chat.model'); - } - - private selectedModel(): ConversationUiModel | undefined { - const modelId = this.selectedModelId || this.modelCatalog.session_model_id || this.modelCatalog.default_models.primary || ''; - if (modelId.length === 0) { - return undefined; - } - return this.modelCatalog.models.find((model: ConversationUiModel) => model.id === modelId); - } - - private primaryModelLabel(model: ConversationUiModel): string { - const modelName = this.cleanModelLabel(model.model_name || ''); - if (this.isSpecificModelLabel(modelName)) { - return modelName; - } - const name = this.cleanModelLabel(model.name || ''); - if (this.isSpecificModelLabel(name)) { - return name; - } - const id = this.cleanModelLabel(model.id || ''); - if (id.length > 0) { - return id; - } - return RemoteI18n.t('chat.model'); - } - - private secondaryModelLabel(model: ConversationUiModel): string { - const provider = this.cleanModelLabel(model.provider || ''); - const name = this.cleanModelLabel(model.name || ''); - const primary = this.primaryModelLabel(model); - if (provider.length > 0 && name.length > 0 && name !== primary && name !== provider) { - return `${provider} · ${name}`; - } - if (provider.length > 0 && provider !== primary) { - return provider; - } - if (name.length > 0 && name !== primary) { - return name; - } - return model.id || primary; - } - - private cleanModelLabel(value: string): string { - const trimmed = (value || '').trim(); - if (trimmed.length === 0) { - return ''; - } - const withoutScheme = trimmed.replace(/^openbitfun[:/_-]+/i, '').replace(/^anthropic[:/_-]+/i, ''); - const parts = withoutScheme.split(/[/:]/).filter((part: string) => part.length > 0); - return parts.length > 0 ? parts[parts.length - 1] : withoutScheme; - } - - private isSpecificModelLabel(label: string): boolean { - const normalized = label.toLowerCase(); - return label.length > 0 && - normalized !== 'openbitfun' && - normalized !== 'anthropic' && - normalized !== 'openai' && - normalized !== 'google' && - normalized !== 'azure' && - normalized !== 'bitfun'; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 3a1cbdf6c7..9ed4245319 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -1,13 +1,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -const REMOTE_SETTINGS_BG: string = '#F4F4F7'; -const REMOTE_SETTINGS_BLUE: string = '#0A84FF'; - @Component export struct RemoteControlSettingsSheet { @Prop desktopName: string = ''; @@ -74,8 +71,8 @@ export struct RemoteControlSettingsSheet { } .width('100%') .height('100%') - .backgroundColor(REMOTE_SETTINGS_BG) - .borderRadius(this.openAccountOnAppear ? 0 : { topLeft: 34, topRight: 34 }) + .backgroundColor(PAGE_BG) + .borderRadius({ topLeft: 34, topRight: 34 }) } @Builder @@ -117,10 +114,11 @@ export struct RemoteControlSettingsSheet { .fontWeight(FontWeight.Medium) .fontColor(INK) .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) .opacity(0.52) } .width('100%') @@ -144,7 +142,7 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.currentControl')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) } .width('100%') .height(42) @@ -156,15 +154,16 @@ export struct RemoteControlSettingsSheet { private CurrentControlCard() { Column() { Row({ space: 14 }) { - Image($r('app.media.remote_ref_device')) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(23) + .fontColor([MUTED]) .width(28) - .height(24) - .objectFit(ImageFit.Contain) + .height(26) .opacity(0.58) Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.desktopProduct')) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) Text(this.connectionTitle()) .fontSize(18) .fontWeight(FontWeight.Medium) @@ -173,7 +172,7 @@ export struct RemoteControlSettingsSheet { .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.connectionDetail()) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -192,20 +191,21 @@ export struct RemoteControlSettingsSheet { .margin({ left: 18, right: 18 }) Row({ space: 10 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(18) + .fontColor([MUTED]) .width(20) .height(20) - .objectFit(ImageFit.Contain) .opacity(0.58) Text(RemoteI18n.t('remote.settings.connectionSource')) .fontSize(14) - .fontColor('#929298') + .fontColor(MUTED) Blank() Text(this.connectionSourceLabel()) .fontSize(13) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 5, bottom: 5 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(12) } .width('100%') @@ -223,17 +223,17 @@ export struct RemoteControlSettingsSheet { if (this.isConnectedOrConnecting()) { Text(RemoteI18n.t('remote.settings.disconnect')) .fontSize(14) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 7, bottom: 7 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => this.onDisconnect()) } else if (this.hasConnectionProjection()) { Text(RemoteI18n.t('remote.settings.reconnect')) .fontSize(14) - .fontColor(REMOTE_SETTINGS_BLUE) + .fontColor(INK) .padding({ left: 10, right: 10, top: 7, bottom: 7 }) - .backgroundColor('#EDF5FF') + .backgroundColor(SOFT) .borderRadius(14) .onClick(() => this.onReconnect()) } @@ -245,15 +245,16 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.otherConnectionMethods')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) .width('100%') .height(42) .padding({ left: 18, right: 16 }) Row({ space: 12 }) { - Image($r('app.media.remote_actions_link')) + SymbolGlyph($r('sys.symbol.link')) + .fontSize(21) + .fontColor([MUTED]) .width(24) .height(24) - .objectFit(ImageFit.Contain) .opacity(0.62) Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.qrConnect')) @@ -262,15 +263,16 @@ export struct RemoteControlSettingsSheet { .fontColor(INK) Text(RemoteI18n.t('remote.settings.qrConnectBody')) .fontSize(13) - .fontColor('#929298') + .fontColor(MUTED) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) .opacity(0.52) } .width('100%') @@ -290,12 +292,12 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.permissions.title')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) Blank() if (this.canManagePermissions()) { Text(RemoteI18n.t('common.refresh')) .fontSize(14) - .fontColor(this.permissionModeBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .fontColor(this.permissionModeBusy ? MUTED : INK) .onClick(() => this.refreshPermissionMode()) } } @@ -337,7 +339,7 @@ export struct RemoteControlSettingsSheet { .width('100%') .height(28) .fontSize(12) - .fontColor(this.permissionModeError.length > 0 ? '#D04A3A' : MUTED) + .fontColor(this.permissionModeError.length > 0 ? RED : MUTED) .padding({ left: 18, right: 18, top: 4 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -356,10 +358,11 @@ export struct RemoteControlSettingsSheet { Row({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { if (this.permissionModeLoaded && this.permissionMode === mode) { - Image($r('app.media.remote_actions_check')) + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(19) + .fontColor([INK]) .width(20) .height(20) - .objectFit(ImageFit.Contain) } } .width(22) @@ -394,7 +397,7 @@ export struct RemoteControlSettingsSheet { .width('100%') .fontSize(15) .fontWeight(FontWeight.Bold) - .fontColor('#B9382D') + .fontColor(RED) Text(RemoteI18n.t('remote.permissions.fullAccessWarningBody')) .width('100%') .fontSize(13) @@ -406,15 +409,15 @@ export struct RemoteControlSettingsSheet { .height(42) .fontSize(14) .fontColor(INK) - .backgroundColor('#F0EFEC') + .backgroundColor(SOFT) .borderRadius(21) .onClick(() => { this.confirmFullAccess = false; }) Button(RemoteI18n.t('remote.permissions.confirmFullAccess')) .layoutWeight(1) .height(42) .fontSize(14) - .fontColor(CARD) - .backgroundColor('#C63E3E') + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(RED) .borderRadius(21) .onClick(() => this.applyPermissionMode('full_access')) } @@ -422,8 +425,8 @@ export struct RemoteControlSettingsSheet { } .width('100%') .padding({ left: 16, right: 16, top: 14, bottom: 16 }) - .backgroundColor('#FFF3F1') - .border({ width: 1, color: '#F0B3AA' }) + .backgroundColor(CARD) + .border({ width: 1, color: RED }) .borderRadius(18) .margin({ left: 12, right: 12, bottom: 14 }) } @@ -447,10 +450,11 @@ export struct RemoteControlSettingsSheet { Column({ space: 0 }) { Row() { Button() { - Image($r('app.media.remote_ref_back')) + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23) + .fontColor([INK]) .width(26) .height(26) - .objectFit(ImageFit.Contain) } .width(44) .height(44) @@ -503,7 +507,7 @@ export struct RemoteControlSettingsSheet { Text(RemoteI18n.t('remote.settings.profileDetails')) .fontSize(18) .fontWeight(FontWeight.Bold) - .fontColor('#929298') + .fontColor(MUTED) .width('100%') .margin({ left: 18, bottom: 8 }) @@ -550,15 +554,17 @@ export struct RemoteControlSettingsSheet { .fontColor(MUTED) .width('100%') - Button(RemoteI18n.t('remote.settings.accountSync')) + Text(RemoteI18n.t('remote.settings.accountSync')) .height(42) .width('100%') .fontSize(15) - .fontColor(REMOTE_SETTINGS_BLUE) - .backgroundColor('#E8F2FF') + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) .borderRadius(21) - .enabled(!this.cloudSyncBusy) + .opacity(this.cloudSyncBusy ? 0.52 : 1) .onClick(async () => { + if (this.cloudSyncBusy) return; this.cloudSyncBusy = true; this.cloudSyncStatus = ''; try { @@ -584,15 +590,16 @@ export struct RemoteControlSettingsSheet { @Builder private LogoutAction() { Row({ space: 14 }) { - Image($r('app.media.settings_logout_arrow')) + SymbolGlyph($r('sys.symbol.arrow_right_and_square')) + .fontSize(22) + .fontColor([RED]) .width(24) .height(24) - .objectFit(ImageFit.Contain) Text(this.logoutBusy ? RemoteI18n.t('remote.settings.accountLoggingOut') : RemoteI18n.t('remote.settings.accountLogout')) .fontSize(17) .fontWeight(FontWeight.Medium) - .fontColor('#E11D17') + .fontColor(RED) } .width('100%') .height(62) @@ -624,7 +631,7 @@ export struct RemoteControlSettingsSheet { Blank() Text(this.accountDevicesBusy ? RemoteI18n.t('remote.settings.deviceLoading') : RemoteI18n.t('remote.settings.deviceRefresh')) - .fontSize(13).fontColor(this.accountDevicesBusy ? MUTED : REMOTE_SETTINGS_BLUE) + .fontSize(13).fontColor(this.accountDevicesBusy ? MUTED : INK) .onClick(async () => { await this.refreshAccountDevices(); }) @@ -652,8 +659,8 @@ export struct RemoteControlSettingsSheet { @Builder private AccountDeviceRow(device: CloudAccountDevice) { Row({ space: 12 }) { - Image($r('app.media.remote_ref_device')) - .width(24).height(22).objectFit(ImageFit.Contain).opacity(0.58) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(20).fontColor([MUTED]).width(24).height(22).opacity(0.72) Column({ space: 2 }) { Text(device.deviceName || device.deviceId) .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) @@ -719,10 +726,11 @@ export struct RemoteControlSettingsSheet { @Builder private CloseButton() { Button() { - Image($r('app.media.settings_close_x')) - .width(28) - .height(28) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(21) + .fontColor([INK]) + .width(24) + .height(24) } .width(50) .height(50) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets index a278d258af..094a022884 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -3,18 +3,24 @@ import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; +import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, RED, SOFT, SUBTLE } from './Theme'; +import { ComposerPresentation } from './ComposerBar'; +import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct RemoteCreateSessionView { @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param presentation: ComposerPresentation = ComposerPresentation.Create; + @Param showSidebarRestoreButton: boolean = false; @Event onBack: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; @Event onToggleDeviceMenu: () => void = () => {}; @Event onToggleWorkspaceMenu: () => void = () => {}; @Event onSelectDevice: (device: CloudAccountDevice) => void = (_device: CloudAccountDevice) => {}; @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; @Event onDraftChange: (value: string) => void = (_value: string) => {}; @Event onSend: () => void = () => {}; + @Local showSelectorSheet: boolean = false; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; aboutToAppear(): void { @@ -29,43 +35,59 @@ export struct RemoteCreateSessionView { build() { Column() { - Column() { - Row() { - Button() { - Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) - .objectFit(ImageFit.Contain) - } - .width(48) - .height(48) - .padding(0) - .type(ButtonType.Circle) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) - .onClick(() => this.onBack()) - } - .width('100%') - .height(78) - .padding({ left: 18, top: 14 }) - .alignItems(VerticalAlign.Top) - Column() - .width('100%') - .layoutWeight(1) - .onClick(() => { - this.state.closeMenu(); - this.keepComposerFocused(); - }) - } - .width('100%') - .layoutWeight(1) - this.ContextControls() - this.Composer() + this.Header() + Blank() + .layoutWeight(1) + .onClick(() => { + this.state.closeMenu(); + this.keepComposerFocused(); + }) + this.TaskComposer() } .width('100%') .height('100%') .backgroundColor(PAGE_BG) + .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) + } + + @Builder + Header() { + Row({ space: 8 }) { + if (this.showSidebarRestoreButton) { + SidebarToggleButton({ + restore: true, + controlSize: 48, + onToggle: this.onRestoreSidebar + }) + } else if (this.presentation !== ComposerPresentation.Floating) { + Button() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(22) + .fontColor([INK]) + } + .width(48) + .height(48) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(24) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => this.onBack()) + } else { + Blank().width(48).height(48) + } + Text(RemoteI18n.t('remote.create.title')) + .layoutWeight(1) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .textAlign(TextAlign.Center) + Blank().width(48).height(48) + } + .width('100%') + .height(64) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) } @Builder @@ -73,41 +95,38 @@ export struct RemoteCreateSessionView { Column({ space: 2 }) { this.ContextRow( 'device', - this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice'), this.state.isLoadingDevices, () => this.onToggleDeviceMenu() ) this.ContextRow( 'workspace', - this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat'), this.state.isLoadingWorkspaces, () => this.onToggleWorkspaceMenu() ) } .width('100%') - .padding({ left: 28, right: 28, bottom: 4 }) + .padding({ left: 10, right: 10, top: 8, bottom: 4 }) } @Builder - ContextRow(kind: string, title: string, loading: boolean, onClick: () => void) { + ContextRow(kind: string, loading: boolean, onClick: () => void) { Row({ space: 13 }) { - Image(kind === 'device' ? $r('app.media.remote_create_device') : - (this.state.selectedWorkspacePath.length > 0 ? $r('app.media.remote_create_folder') : $r('app.media.remote_create_chat'))) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) - .opacity(loading ? 0.42 : 1) - Text(loading ? RemoteI18n.t('common.loading') : title) + this.ContextGlyph(kind, loading) + Text(loading ? RemoteI18n.t('common.loading') : + (kind === 'device' ? + (this.state.selectedDeviceName || RemoteI18n.t('remote.create.noDevice')) : + (this.state.selectedWorkspaceName || RemoteI18n.t('remote.create.chat')))) .constraintSize({ maxWidth: '74%' }) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(loading ? MUTED : INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Image($r('app.media.remote_create_switch')) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(13) + .fontColor([MUTED]) .width(22) .height(30) - .objectFit(ImageFit.Contain) .opacity(loading ? 0.42 : 1) Blank() .layoutWeight(1) @@ -116,12 +135,13 @@ export struct RemoteCreateSessionView { .height(48) .padding({ left: 8, right: 10 }) .borderRadius(12) - .bindPopup(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { + .bindPopup(this.presentation === ComposerPresentation.Floating && + this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { builder: () => { if (kind === 'device') { - this.DeviceMenu() + this.DeviceMenu(false) } else { - this.WorkspaceMenu() + this.WorkspaceMenu(false) } }, placement: Placement.Top, @@ -138,12 +158,30 @@ export struct RemoteCreateSessionView { }) .onClick(() => { onClick(); - this.keepComposerFocused(); + if (this.presentation === ComposerPresentation.Floating) { + this.keepComposerFocused(); + } else { + this.showSelectorSheet = this.state.openMenu !== 'none'; + } }) } @Builder - DeviceMenu() { + ContextGlyph(kind: string, loading: boolean) { + if (kind === 'device') { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } else if (this.state.selectedWorkspacePath.length > 0) { + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } else { + SymbolGlyph($r('sys.symbol.message')) + .fontSize(22).fontColor([MUTED]).width(26).height(26).opacity(loading ? 0.42 : 1) + } + } + + @Builder + DeviceMenu(asSheet: boolean) { Column() { if (this.state.devices.length === 0 && this.state.isLoadingDevices) { this.MenuMessage(RemoteI18n.t('common.loading')) @@ -155,22 +193,20 @@ export struct RemoteCreateSessionView { }, (device: CloudAccountDevice) => device.deviceId) } } - .width(340) + .width(asSheet ? '100%' : 360) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) - .borderRadius(24) + .borderRadius(asSheet ? 0 : 16) .border({ width: 1, color: LINE }) - .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) } @Builder DeviceMenuRow(device: CloudAccountDevice) { Row({ space: 12 }) { this.SelectionMark(device.deviceId === this.state.selectedDeviceId) - Image($r('app.media.remote_create_device')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Text(device.deviceName) .layoutWeight(1) .fontSize(16) @@ -182,20 +218,19 @@ export struct RemoteCreateSessionView { .height(58) .padding({ left: 16, right: 18, top: 8, bottom: 8 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectDevice(device); this.keepComposerFocused(); }) } @Builder - WorkspaceMenu() { + WorkspaceMenu(asSheet: boolean) { Column() { Row({ space: 12 }) { this.SelectionMark(this.state.selectedWorkspacePath.length === 0) - Image($r('app.media.remote_create_chat')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.message')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Text(RemoteI18n.t('remote.create.chat')) .layoutWeight(1) .fontSize(16) @@ -205,6 +240,7 @@ export struct RemoteCreateSessionView { .height(58) .padding({ left: 16, right: 18 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectWorkspace(undefined); this.keepComposerFocused(); }) @@ -228,22 +264,20 @@ export struct RemoteCreateSessionView { .scrollBar(BarState.Off) } } - .width(340) + .width(asSheet ? '100%' : 360) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) - .borderRadius(24) + .borderRadius(asSheet ? 0 : 16) .border({ width: 1, color: LINE }) - .shadow({ radius: 26, color: '#1A000000', offsetY: 10 }) + .shadow({ radius: asSheet ? 0 : 20, color: asSheet ? '#00000000' : '#1A000000', offsetY: 8 }) } @Builder WorkspaceMenuRow(workspace: RecentWorkspaceEntry) { Row({ space: 12 }) { this.SelectionMark(workspace.path === this.state.selectedWorkspacePath) - Image($r('app.media.remote_create_folder')) - .width(27) - .height(27) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22).fontColor([MUTED]).width(27).height(27) Column({ space: 2 }) { Text(workspace.name) .width('100%') @@ -265,6 +299,7 @@ export struct RemoteCreateSessionView { .height(64) .padding({ left: 16, right: 18 }) .onClick(() => { + this.showSelectorSheet = false; this.onSelectWorkspace(workspace); this.keepComposerFocused(); }) @@ -274,10 +309,9 @@ export struct RemoteCreateSessionView { SelectionMark(selected: boolean) { Stack({ alignContent: Alignment.Center }) { if (selected) { - Image($r('app.media.remote_actions_check')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(19) + .fontColor([INK]) } } .width(20) @@ -294,14 +328,50 @@ export struct RemoteCreateSessionView { } @Builder - Composer() { + SelectorSheet() { + Column() { + Row() { + Text(this.state.openMenu === 'devices' ? RemoteI18n.t('remote.device') : RemoteI18n.t('remote.workspace')) + .layoutWeight(1) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.closeSelectorSheet()) + } + .width('100%') + .height(52) + .padding({ left: 18, right: 8 }) + if (this.state.openMenu === 'devices') { + this.DeviceMenu(true) + } else { + this.WorkspaceMenu(true) + } + } + .width('100%') + .padding({ left: 12, right: 12, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(16) + } + + @Builder + TaskComposer() { Column({ space: 6 }) { + this.ContextControls() + Divider() + .color(LINE) + .margin({ left: 18, right: 18 }) if (this.state.errorText.length > 0) { Text(this.state.errorText) .width('100%') .padding({ left: 12, right: 12 }) .fontSize(12) - .fontColor('#C63E3E') + .fontColor(RED) .maxLines(2) } Row({ space: 8 }) { @@ -323,10 +393,9 @@ export struct RemoteCreateSessionView { this.onDraftChange(value); }) Button() { - Image($r('app.media.gpt_composer_send')) - .width(40) - .height(40) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontColor([INK]) .opacity(this.canSend() ? 1 : 0.36) } .width(42) @@ -338,15 +407,26 @@ export struct RemoteCreateSessionView { .onClick(() => this.onSend()) } .width('100%') - .height(66) + .height(72) .padding({ left: 12, right: 7, top: 5, bottom: 5 }) - .backgroundColor(CARD) - .borderRadius(25) - .border({ width: 1, color: SOFT }) - .shadow({ radius: 22, color: '#18000000', offsetY: 7 }) } .width('100%') - .padding({ left: 16, right: 16, bottom: 14 }) + .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) + .alignSelf(ItemAlign.Center) + .padding({ top: 4, bottom: 4 }) + .backgroundColor(CARD) + .borderRadius(18) + .border({ width: 1, color: SOFT }) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 20 : 12, + color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 2 + }) + .margin({ + left: this.presentation === ComposerPresentation.Floating ? 24 : 16, + right: this.presentation === ComposerPresentation.Floating ? 24 : 16, + bottom: this.presentation === ComposerPresentation.Floating ? 24 : 14 + }) } private canSend(): boolean { @@ -357,4 +437,23 @@ export struct RemoteCreateSessionView { private keepComposerFocused(): void { setTimeout(() => focusControl.requestFocus('remote-create-composer'), 30); } + + private closeSelectorSheet(): void { + this.showSelectorSheet = false; + this.state.closeMenu(); + this.keepComposerFocused(); + } + + private selectorSheetOptions(): SheetOptions { + return { + height: this.state.openMenu === 'devices' ? 420 : 440, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true, + onWillDismiss: () => { + this.state.closeMenu(); + } + }; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets index 8c2a746d96..6c6819b035 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets @@ -24,7 +24,7 @@ export struct RemoteHeader { LoadingProgress() .width(14) .height(14) - .color('#B9B9B9') + .color(MUTED) } else { Text('') .width(7) @@ -32,10 +32,11 @@ export struct RemoteHeader { .backgroundColor(this.connectionColor()) .borderRadius(4) } - Image($r('app.media.remote_ref_device')) + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(16) + .fontColor([MUTED]) .width(19) - .height(15) - .objectFit(ImageFit.Contain) + .height(18) Text(this.desktopName || RemoteI18n.t('remote.settings.noDesktop')) .fontSize(13) .fontColor(MUTED) @@ -55,10 +56,11 @@ export struct RemoteHeader { @Builder private MenuButton() { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_menu')) + SymbolGlyph($r('sys.symbol.line_3_horizontal')) + .fontSize(22) + .fontColor([INK]) .width(24) - .height(15) - .objectFit(ImageFit.Contain) + .height(24) } .width(48) .height(48) @@ -73,10 +75,13 @@ export struct RemoteHeader { @Builder private MoreButton() { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(27) - .height(8) - .objectFit(ImageFit.Contain) + Row({ space: 4 }) { + Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) + Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) + Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) + } + .height(10) + .alignItems(VerticalAlign.Center) } .width(48) .height(48) @@ -88,7 +93,7 @@ export struct RemoteHeader { }) } - private connectionColor(): string { + private connectionColor(): ResourceColor { const tone = ConnectionStatusPresenter.tone(this.connectionState); if (tone === 'ok') { return GREEN; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets index b60a7e8851..fc2da92dc5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets @@ -6,13 +6,22 @@ import { RemoteHeader } from './RemoteHeader'; import { RemoteSessionList } from './RemoteSessionList'; import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; import { RemotePageState } from '../state/RemotePageState'; -import { ACCENT, CARD, INK, MUTED, PAGE_BG } from './Theme'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { CARD, INK, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT } from './Theme'; +import { ConversationViewSettings } from './ConversationViewSettings'; @ComponentV2 export struct RemoteHomeView { @Param pageState: RemotePageState = new RemotePageState(); @Param isBusy: boolean = false; @Param selectedSessionId: string = ''; + @Param sortMode: string = 'project'; + @Param workspaceFilter: string = ''; + @Param agentFilter: string = ''; + @Param statusFilter: string = ''; + @Param showWorkspaceMetadata: boolean = false; + @Param showUpdatedMetadata: boolean = false; + @Param showStatusMetadata: boolean = false; @Event onOpenSidebar: () => void = () => {}; @Event onConnectWorkspace: () => void = () => {}; @Event onAddConnection: () => void = () => {}; @@ -35,8 +44,15 @@ export struct RemoteHomeView { @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; + @Event onWorkspaceFilterChange: (value: string) => void = (_value: string) => {}; + @Event onAgentFilterChange: (value: string) => void = (_value: string) => {}; + @Event onStatusFilterChange: (value: string) => void = (_value: string) => {}; + @Event onWorkspaceMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; + @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; @Local showRemoteActions: boolean = false; - @Local sortMode: string = 'project'; + @Local showViewSettings: boolean = false; build() { Stack() { @@ -72,10 +88,16 @@ export struct RemoteHomeView { sessions: this.pageState.visibleSessions(), query: this.pageState.sessionQuery, sortMode: this.sortMode, + workspaceFilter: this.workspaceFilter, + agentFilter: this.agentFilter, + statusFilter: this.statusFilter, workspaceName: this.pageState.workspaceName, workspacePath: this.pageState.workspacePath, workspaceKind: this.pageState.workspaceKind, recentWorkspaces: this.pageState.recentWorkspaces, + showWorkspaceMetadata: this.showWorkspaceMetadata, + showUpdatedMetadata: this.showUpdatedMetadata, + showStatusMetadata: this.showStatusMetadata, hasMoreSessions: this.pageState.hasMoreSessions, isBusy: this.isBusy || this.pageState.isLoadingSessions, onCreate: () => { @@ -145,6 +167,7 @@ export struct RemoteHomeView { .height('100%') .padding({ left: 12, right: 12, top: 0, bottom: 16 }) .backgroundColor(PAGE_BG) + .bindSheet($$this.showViewSettings, this.ViewSettingsSheet(), this.viewSettingsSheetOptions()) } @Builder @@ -198,13 +221,59 @@ export struct RemoteHomeView { this.onClearPairing(); }, onSortModeChange: (mode: string) => { - this.sortMode = mode; + this.onSortModeChange(mode); }, onAddConnection: () => { this.onAddConnection(); }, onOpenSettings: () => { this.onOpenRemoteSettings(); + }, + onOpenViewSettings: () => { + this.showViewSettings = true; + } + }) + } + + @Builder + ViewSettingsSheet() { + ConversationViewSettings({ + sessions: this.pageState.visibleSessions(), + workspaceName: this.pageState.workspaceName, + workspacePath: this.pageState.workspacePath, + workspaceKind: this.pageState.workspaceKind, + recentWorkspaces: this.pageState.recentWorkspaces, + sortMode: this.sortMode, + workspaceFilter: this.workspaceFilter, + agentFilter: this.agentFilter, + statusFilter: this.statusFilter, + showWorkspaceMetadata: this.showWorkspaceMetadata, + showUpdatedMetadata: this.showUpdatedMetadata, + showStatusMetadata: this.showStatusMetadata, + onSortModeChange: (mode: string) => { + this.onSortModeChange(mode); + }, + onWorkspaceFilterChange: (value: string) => { + RemoteLogger.info(`compact view-settings workspace received=${value.length > 0 ? value : ''}`); + this.onWorkspaceFilterChange(value); + }, + onAgentFilterChange: (value: string) => { + this.onAgentFilterChange(value); + }, + onStatusFilterChange: (value: string) => { + this.onStatusFilterChange(value); + }, + onWorkspaceMetadataChange: (value: boolean) => { + this.onWorkspaceMetadataChange(value); + }, + onUpdatedMetadataChange: (value: boolean) => { + this.onUpdatedMetadataChange(value); + }, + onStatusMetadataChange: (value: boolean) => { + this.onStatusMetadataChange(value); + }, + onClose: () => { + this.showViewSettings = false; } }) } @@ -224,13 +293,14 @@ export struct RemoteHomeView { .fontColor(MUTED) .textAlign(TextAlign.Center) .constraintSize({ maxWidth: 280 }) - Button(RemoteI18n.t('connect.connect')) + Text(RemoteI18n.t('connect.connect')) .width(148) .height(50) .fontSize(16) .fontWeight(FontWeight.Medium) - .fontColor(CARD) - .backgroundColor(ACCENT) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) .borderRadius(25) .margin({ top: 14 }) .onClick(() => { @@ -246,23 +316,10 @@ export struct RemoteHomeView { @Builder LargeDesktopGlyph() { - Stack() { - Text('') - .width(46) - .height(34) - .borderRadius(6) - .border({ width: 2, color: INK }) - .position({ x: 9, y: 4 }) - Text('') - .width(18) - .height(2) - .backgroundColor(INK) - .position({ x: 23, y: 44 }) - Text('') - .width(34) - .height(2) - .backgroundColor(INK) - .position({ x: 15, y: 51 }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(50) + .fontColor([INK]) } .width(64) .height(58) @@ -300,4 +357,14 @@ export struct RemoteHomeView { return this.pageState.isLoadingHome || this.isConnecting(); } + private viewSettingsSheetOptions(): SheetOptions { + return { + height: 520, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true + }; + } + } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index 712bf31a97..b468b95803 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -1,7 +1,11 @@ import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; -import { CARD, INK, MUTED, RED } from './Theme'; +import { CARD, INK, MUTED, SOFT } from './Theme'; +import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionDetailsView } from './SessionDetailsView'; +import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -15,6 +19,13 @@ export struct RemoteSessionList { @Param workspacePath: string = ''; @Param workspaceKind: string = 'normal'; @Param recentWorkspaces: RecentWorkspaceEntry[] = []; + @Param actionPresentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; + @Param workspaceFilter: string = ''; + @Param agentFilter: string = ''; + @Param statusFilter: string = ''; + @Param showWorkspaceMetadata: boolean = false; + @Param showUpdatedMetadata: boolean = false; + @Param showStatusMetadata: boolean = false; @Event onCreate: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -31,6 +42,10 @@ export struct RemoteSessionList { @Local yesterdayCollapsed: boolean = false; @Local earlierCollapsed: boolean = false; @Local createMenuPath: string = ''; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { @@ -41,25 +56,44 @@ export struct RemoteSessionList { Column() { Scroll() { Column() { - if (this.sortMode === 'time') { + if (this.filteredSessions().length === 0) { + if (this.hasActiveListFilter()) { + this.FilteredEmptySessions() + } else { + this.EmptySessions() + } + } else if (this.sortMode === 'time') { this.TimeSection() } else if (this.sortMode === 'chat') { - this.ChatSection() - this.ProjectSection() + if (this.visibleChatSessions().length > 0) { + this.ChatSection() + } + if (this.projectEntries().length > 0) { + this.ProjectSection() + } } else { - this.ProjectSection() - this.ChatSection() + if (this.projectEntries().length > 0) { + this.ProjectSection() + } + if (this.visibleChatSessions().length > 0) { + this.ChatSection() + } } } .width('100%') + .alignItems(HorizontalAlign.Start) } .layoutWeight(1) .width('100%') + .align(Alignment.TopStart) .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) } .width('100%') .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) + .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @Builder @@ -80,7 +114,7 @@ export struct RemoteSessionList { } } .width('100%') - .margin({ top: 14, bottom: 16 }) + .margin({ top: 10, bottom: 16 }) } @Builder @@ -135,25 +169,23 @@ export struct RemoteSessionList { private TimeGroupHeader(title: string, collapsed: boolean, action: () => void) { Row({ space: 8 }) { Text(title) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) if (collapsed) { - Image($r('app.media.remote_ref_chevron_right')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .opacity(0.55) } else { - Image($r('app.media.remote_ref_chevron_centered')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .opacity(0.55) } } .width('100%') - .height(50) + .height(42) .alignItems(VerticalAlign.Center) .onClick(action) } @@ -164,47 +196,51 @@ export struct RemoteSessionList { Row() { Row({ space: 8 }) { Text(RemoteI18n.t('sidebar.projects')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(`${this.projectEntries().length}`) .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(`${this.projectEntries().length}`) + .fontSize(12) .fontColor(MUTED) } Blank() } .width('100%') - .height(58) + .height(44) .alignItems(VerticalAlign.Center) ForEach(this.visibleProjectEntries(), (project: RecentWorkspaceEntry) => { Column({ space: 2 }) { Row({ space: 10 }) { - Image($r('app.media.remote_ref_folder')) - .width(30) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.folder')) + .fontSize(22) + .fontColor([INK]) + .width(24) + .height(24) Text(project.name || this.basename(project.path)) - .fontSize(18) + .fontSize(15) .fontColor(INK) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.isWorkspaceCollapsed(project.path)) { - Image($r('app.media.remote_ref_chevron_right')) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } else { - Image($r('app.media.remote_ref_chevron_centered')) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } if (project.path.length > 0) { - Image($r('app.media.remote_ref_edit')) - .width(25) - .height(25) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(18) + .fontColor([MUTED]) + .width(22) + .height(22) .opacity(0.52) .bindPopup(this.createMenuPath.length > 0 && this.createMenuPath === project.path, { builder: () => { @@ -228,7 +264,7 @@ export struct RemoteSessionList { } } .width('100%') - .height(50) + .height(44) .onClick(() => { this.toggleWorkspace(project.path); }) @@ -241,9 +277,9 @@ export struct RemoteSessionList { }, (project: RecentWorkspaceEntry): string => project.path) if (this.visibleProjectCount < this.projectEntries().length) { Text(RemoteI18n.f('remote.projects.showMore', String(this.nextProjectEntryBatchSize()))) - .fontSize(15) + .fontSize(13) .fontColor(MUTED) - .height(48) + .height(40) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { @@ -252,7 +288,7 @@ export struct RemoteSessionList { } } .width('100%') - .margin({ top: 18 }) + .margin({ top: 12 }) } @Builder @@ -292,36 +328,39 @@ export struct RemoteSessionList { Row() { Row({ space: 8 }) { Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) if (this.chatsCollapsed) { - Image($r('app.media.remote_ref_chevron_right')) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } else { - Image($r('app.media.remote_ref_chevron_centered')) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(14) + .fontColor([MUTED]) .width(18) .height(18) - .objectFit(ImageFit.Contain) } } .onClick(() => { this.chatsCollapsed = !this.chatsCollapsed; }) Blank() - Image($r('app.media.remote_ref_edit')) - .width(26) - .height(26) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(18) + .fontColor([MUTED]) + .width(22) + .height(22) .opacity(0.52) .onClick(() => { this.onCreateAssistantSession(); }) } .width('100%') - .height(58) + .height(44) .alignItems(VerticalAlign.Center) if (this.visibleChatSessions().length === 0) { this.EmptySessions() @@ -331,9 +370,9 @@ export struct RemoteSessionList { }) if (this.chatVisibleCount < this.visibleChatSessions().length) { Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextChatBatchSize()))) - .fontSize(15) + .fontSize(13) .fontColor(MUTED) - .height(48) + .height(40) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { @@ -349,24 +388,7 @@ export struct RemoteSessionList { @Builder private projectPreview(path: string) { ForEach(this.visibleProjectSessions(path), (item: RemoteSession) => { - Row() { - Text(item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(16) - .fontColor(INK) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Start) - } - .width('100%') - .height(58) - .padding({ left: 0, right: 8 }) - .backgroundColor(this.selectedSessionId === item.id ? '#F3F3F3' : '#00000000') - .borderRadius(12) - .onClick(() => { - this.onOpenSession(item); - }) + this.SessionRow(item, true) }) if (this.projectVisibleCount(path) < this.projectSessions(path).length) { Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(path)))) @@ -392,7 +414,10 @@ export struct RemoteSessionList { entries.push(item); } }); - return entries; + if (!this.hasActiveListFilter()) { + return entries; + } + return entries.filter((item: RecentWorkspaceEntry) => this.projectSessions(item.path).length > 0); } private visibleProjectEntries(): RecentWorkspaceEntry[] { @@ -409,23 +434,11 @@ export struct RemoteSessionList { } private visibleChatSessions(): RemoteSession[] { - const query = this.query.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { - if (!this.isAssistantSession(item) || item.status === 'archived') { - return false; - } - return query.length === 0 || item.title.toLowerCase().indexOf(query) >= 0; - }); + return this.filteredSessions().filter((item: RemoteSession) => this.isAssistantSession(item)); } private sessionsByTime(): RemoteSession[] { - const query = this.query.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { - if (item.status === 'archived') { - return false; - } - return query.length === 0 || item.title.toLowerCase().indexOf(query) >= 0; - }).slice().sort((left: RemoteSession, right: RemoteSession) => { + return this.filteredSessions().slice().sort((left: RemoteSession, right: RemoteSession) => { return this.sessionTimestamp(right) - this.sessionTimestamp(left); }); } @@ -466,11 +479,33 @@ export struct RemoteSessionList { } private projectSessions(path: string): RemoteSession[] { + return this.filteredSessions().filter((item: RemoteSession) => { + return !this.isAssistantSession(item) && ConversationSessionFilterPolicy.workspacePathsEqual( + item.workspacePath || this.workspacePath, + path + ); + }); + } + + private filteredSessions(): RemoteSession[] { return this.sessions.filter((item: RemoteSession) => { - return !this.isAssistantSession(item) && (item.workspacePath || this.workspacePath) === path; + return ConversationSessionFilterPolicy.matches( + item, + this.query, + this.workspacePath, + this.workspaceFilter, + this.agentFilter, + this.statusFilter, + this.isAssistantSession(item) + ); }); } + private hasActiveListFilter(): boolean { + return this.query.trim().length > 0 || this.workspaceFilter.length > 0 || + this.agentFilter.length > 0 || this.statusFilter.length > 0; + } + private visibleProjectSessions(path: string): RemoteSession[] { return this.projectSessions(path).slice(0, this.projectVisibleCount(path)); } @@ -520,28 +555,103 @@ export struct RemoteSessionList { } @Builder - private SessionRow(item: RemoteSession) { - Row() { - Text(item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(18) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text('›') - .fontSize(22) - .fontColor('#00000000') + private SessionRow(item: RemoteSession, nested: boolean = false) { + Row({ space: 8 }) { + Column({ space: 2 }) { + Text(item.title || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(15) + .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.metadataText(item).length > 0) { + Text(this.metadataText(item)) + .width('100%') + .fontSize(11) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + this.SessionMoreButton(item) } .width('100%') - .height(58) - .padding({ left: 12, right: 12 }) + .height(this.metadataText(item).length > 0 ? 56 : 46) + .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? '#F3F3F3' : '#00000000') - .borderRadius(12) + .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .borderRadius(10) .onClick(() => { this.onOpenSession(item); }) + .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) + .bindPopup(this.actionPresentation === SessionActionPresentation.Popover && + this.activeActionSessionId === item.id, { + builder: () => { this.SessionActionPopover() }, + placement: Placement.Right, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 6, + onStateChange: (event) => { + if (!event.isVisible) { + this.closeSessionActions(); + } + } + }) + } + + @Builder + private SessionMoreButton(item: RemoteSession) { + Stack({ alignContent: Alignment.Center }) { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + .width(34) + .height(40) + .opacity(0.62) + .accessibilityText(RemoteI18n.t('session.actions')) + .onClick(() => this.openSessionActions(item)) + } + + @Builder + private SessionActionSheet() { + this.SessionActionContent(SessionActionPresentation.BottomSheet) + } + + @Builder + private SessionActionPopover() { + this.SessionActionContent(SessionActionPresentation.Popover) + } + + @Builder + private SessionActionContent(presentation: SessionActionPresentation) { + SessionActionSurface({ + presentation, + sessionTitle: this.actionSessionTitle(), + canViewDetails: this.actionCapabilities().canViewDetails, + canDelete: this.actionCapabilities().canDelete, + onViewDetails: () => this.openActionSessionDetails(), + onDelete: () => this.deleteActionSession(), + onClose: () => this.closeSessionActions() + }) + } + + @Builder + private SessionDetailsSheet() { + SessionDetailsView({ + session: this.detailsSession(), + onClose: () => this.closeSessionDetails() + }) } @Builder @@ -565,34 +675,128 @@ export struct RemoteSessionList { } @Builder - private DeleteReveal(item: RemoteSession) { - Text(RemoteI18n.t('home.deleteSession')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(92) - .height(58) - .backgroundColor(RED) - .onClick(() => { - this.onDeleteSession(item); - }) + private FilteredEmptySessions() { + Column({ space: 8 }) { + Text(this.isBusy ? RemoteI18n.t('home.emptyLoadingTitle') : RemoteI18n.t('remote.emptyTitle')) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + Text(this.isBusy ? RemoteI18n.t('home.emptyLoadingText') : RemoteI18n.t('remote.emptyText')) + .fontSize(14) + .lineHeight(21) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .alignItems(HorizontalAlign.Center) + .padding({ left: 24, right: 24, top: 24, bottom: 16 }) } - private sessionSwipeAction(item: RemoteSession): SwipeActionOptions { + private openSessionActions(item: RemoteSession): void { if (this.isBusy || item.id.length === 0) { - return {}; + return; + } + this.activeActionSessionId = item.id; + if (this.actionPresentation === SessionActionPresentation.BottomSheet) { + this.showSessionActionSheet = true; } + } + + private closeSessionActions(): void { + this.showSessionActionSheet = false; + this.activeActionSessionId = ''; + } + + private actionSession(): RemoteSession | undefined { + return this.sessions.find((item: RemoteSession) => item.id === this.activeActionSessionId); + } + + private actionSessionTitle(): string { + const session = this.actionSession(); + return session ? session.title : ''; + } + + private metadataText(item: RemoteSession): string { + const values: string[] = []; + if (this.showWorkspaceMetadata) { + const workspace = item.workspaceName || item.workspacePath || ''; + if (workspace.length > 0) { + values.push(workspace); + } + } + if (this.showUpdatedMetadata && !Number.isNaN(TimeFormat.timestampMs(item.updatedAt))) { + values.push(TimeFormat.relative(item.updatedAt)); + } + if (this.showStatusMetadata && item.status.length > 0) { + values.push(item.status === 'archived' ? RemoteI18n.t('sidebar.archived') : item.status); + } + return values.join(' · '); + } + + private actionCapabilities(): SessionActionCapabilities { + const session = this.actionSession(); + return SessionActionPolicy.resolve( + SessionActionScope.Remote, + session ? session.agentType : '', + this.isBusy || session === undefined + ); + } + + private deleteActionSession(): void { + const session = this.actionSession(); + if (session) { + this.onDeleteSession(session); + } + } + + private openActionSessionDetails(): void { + const session = this.actionSession(); + if (session) { + this.detailsSessionId = session.id; + this.showSessionDetails = true; + } + } + + private closeSessionDetails(): void { + this.showSessionDetails = false; + this.detailsSessionId = ''; + } + + private detailsSession(): RemoteSession { + const session = this.sessions.find((item: RemoteSession) => item.id === this.detailsSessionId); + return session || { + id: '', title: '', agentType: '', status: '', updatedAt: '', createdAt: '', messageCount: 0 + }; + } + + private sessionActionSheetOptions(): SheetOptions { return { - end: { - builder: () => { - this.DeleteReveal(item); - }, - actionAreaDistance: 92, - onAction: () => { - this.onDeleteSession(item); - } - }, - edgeEffect: SwipeEdgeEffect.None + height: 300, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + + private sessionDetailsSheetOptions(): SheetOptions { + if (this.actionPresentation === SessionActionPresentation.BottomSheet) { + return { + height: SheetSize.LARGE, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false + }; + } + return { + height: 560, + width: 560, + preferType: SheetType.CENTER, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: false }; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets new file mode 100644 index 0000000000..2fad4dff86 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets @@ -0,0 +1,192 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MUTED, RED, SOFT } from './Theme'; + +export enum SessionActionPresentation { + BottomSheet = 'bottom_sheet', + Popover = 'popover' +} + +@ComponentV2 +export struct SessionActionSurface { + @Param presentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; + @Param sessionTitle: string = ''; + @Param archived: boolean = false; + @Param canViewDetails: boolean = false; + @Param canArchive: boolean = false; + @Param canExport: boolean = false; + @Param canDelete: boolean = false; + @Event onArchive: () => void = () => {}; + @Event onViewDetails: () => void = () => {}; + @Event onExport: () => void = () => {}; + @Event onDelete: () => void = () => {}; + @Event onClose: () => void = () => {}; + @Local confirmingDelete: boolean = false; + + build() { + Column() { + if (this.presentation === SessionActionPresentation.BottomSheet) { + Text('') + .width(36) + .height(4) + .backgroundColor(LINE) + .borderRadius(2) + .margin({ bottom: 10 }) + } + + Row({ space: 12 }) { + Column({ space: 3 }) { + Text(RemoteI18n.t('session.actions')) + .width('100%') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(this.sessionTitle || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(40) + .height(40) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .height(52) + .alignItems(VerticalAlign.Center) + + Divider().color(LINE).margin({ top: 6, bottom: 8 }) + + if (this.confirmingDelete) { + this.DeleteConfirmation() + } else { + if (this.canViewDetails) { + this.ActionRow('details', RemoteI18n.t('session.viewDetails'), () => { + this.onViewDetails(); + this.onClose(); + }) + } + if (this.canArchive) { + this.ActionRow('archive', this.archived ? RemoteI18n.t('sidebar.unarchive') : + RemoteI18n.t('sidebar.archive'), () => { + this.onArchive(); + this.onClose(); + }) + } + if (this.canExport) { + this.ActionRow('export', RemoteI18n.t('sidebar.exportMarkdown'), () => { + this.onExport(); + this.onClose(); + }) + } + if (this.canDelete) { + if (this.canArchive || this.canExport) { + Divider().color(LINE).margin({ top: 6, bottom: 6 }) + } + this.ActionRow('delete', RemoteI18n.t('common.delete'), () => { + this.confirmingDelete = true; + }, true) + } + } + } + .width(this.presentation === SessionActionPresentation.Popover ? 300 : '100%') + .padding({ left: 16, right: 16, top: 10, bottom: 18 }) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(16) + .shadow({ + radius: this.presentation === SessionActionPresentation.Popover ? 20 : 0, + color: this.presentation === SessionActionPresentation.Popover ? '#1A000000' : '#00000000', + offsetY: this.presentation === SessionActionPresentation.Popover ? 8 : 0 + }) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private ActionRow(kind: string, label: string, action: () => void, destructive: boolean = false) { + Row({ space: 12 }) { + this.ActionIcon(kind, destructive) + Text(label) + .layoutWeight(1) + .fontSize(15) + .fontColor(destructive ? RED : INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .height(46) + .padding({ left: 10, right: 10 }) + .borderRadius(8) + .backgroundColor(destructive ? SOFT : '#00000000') + .onClick(action) + } + + @Builder + private ActionIcon(kind: string, destructive: boolean) { + if (kind === 'details') { + SymbolGlyph($r('sys.symbol.info_circle')) + .fontSize(19) + .fontColor([MUTED]) + } else if (kind === 'archive') { + SymbolGlyph($r('sys.symbol.archivebox')) + .fontSize(19) + .fontColor([destructive ? RED : MUTED]) + } else if (kind === 'export') { + SymbolGlyph($r('sys.symbol.cloud')) + .fontSize(19) + .fontColor([MUTED]) + } else { + SymbolGlyph($r('sys.symbol.trash')) + .fontSize(19) + .fontColor([RED]) + } + } + + @Builder + private DeleteConfirmation() { + Column({ space: 12 }) { + Text(RemoteI18n.t('sidebar.deleteConfirm')) + .width('100%') + .fontSize(13) + .lineHeight(19) + .fontColor(MUTED) + Row({ space: 10 }) { + Text(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(44) + .fontSize(14) + .fontColor(INK) + .textAlign(TextAlign.Center) + .backgroundColor(SOFT) + .borderRadius(8) + .onClick(() => { + this.confirmingDelete = false; + }) + Text(RemoteI18n.t('common.delete')) + .layoutWeight(1) + .height(44) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(CARD) + .textAlign(TextAlign.Center) + .backgroundColor(RED) + .borderRadius(8) + .onClick(() => { + this.onDelete(); + this.onClose(); + }) + } + .width('100%') + } + .width('100%') + .padding({ top: 4, bottom: 4 }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets new file mode 100644 index 0000000000..008d1a5143 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets @@ -0,0 +1,149 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { TimeFormat } from '../../services/TimeFormat'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; + +@ComponentV2 +export struct SessionDetailsView { + @Param session: RemoteSession = { + id: '', + title: '', + agentType: '', + status: '', + updatedAt: '', + createdAt: '', + messageCount: 0 + }; + @Event onClose: () => void = () => {}; + + build() { + Column() { + Row({ space: 12 }) { + Column({ space: 3 }) { + Text(RemoteI18n.t('session.details')) + .width('100%') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + Text(this.session.title || RemoteI18n.t('sidebar.untitled')) + .width('100%') + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(44) + .height(44) + .backgroundColor(SOFT) + .borderRadius(22) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) + } + .width('100%') + .padding({ left: 20, right: 16, top: 18, bottom: 16 }) + + Divider().color(LINE) + + Scroll() { + Column() { + this.DetailRow(RemoteI18n.t('session.agentType'), this.agentTypeLabel()) + if ((this.session.workspaceName || '').length > 0) { + this.DetailRow(RemoteI18n.t('session.workspace'), this.session.workspaceName || '') + } + if ((this.session.workspacePath || '').length > 0) { + this.PathRow(RemoteI18n.t('session.workspacePath'), this.session.workspacePath || '') + } + if (this.validTime(this.session.createdAt)) { + this.DetailRow(RemoteI18n.t('session.createdAt'), TimeFormat.relative(this.session.createdAt)) + } + if (this.validTime(this.session.updatedAt)) { + this.DetailRow(RemoteI18n.t('session.updatedAt'), TimeFormat.relative(this.session.updatedAt)) + } + this.DetailRow(RemoteI18n.t('session.messageCount'), `${Math.max(0, this.session.messageCount)}`) + if (this.session.status.length > 0) { + this.DetailRow(RemoteI18n.t('session.status'), this.statusLabel()) + } + } + .width('100%') + .padding({ left: 20, right: 20, top: 8, bottom: 24 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius(16) + } + + @Builder + private DetailRow(label: string, value: string) { + Row({ space: 16 }) { + Text(label) + .width(104) + .fontSize(13) + .fontColor(MUTED) + Text(value) + .layoutWeight(1) + .fontSize(15) + .fontColor(INK) + .textAlign(TextAlign.End) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .constraintSize({ minHeight: 52 }) + .padding({ top: 8, bottom: 8 }) + .border({ width: { bottom: 1 }, color: LINE }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private PathRow(label: string, value: string) { + Column({ space: 8 }) { + Text(label) + .width('100%') + .fontSize(13) + .fontColor(MUTED) + Text(value) + .width('100%') + .fontSize(12) + .lineHeight(18) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(6) + .copyOption(CopyOptions.LocalDevice) + } + .width('100%') + .padding({ top: 12, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + .alignItems(HorizontalAlign.Start) + } + + private validTime(value: string): boolean { + return !Number.isNaN(TimeFormat.timestampMs(value)); + } + + private agentTypeLabel(): string { + return this.session.agentType.length > 0 ? this.session.agentType : RemoteI18n.t('common.unknown'); + } + + private statusLabel(): string { + if (this.session.status === 'archived') { + return RemoteI18n.t('sidebar.archived'); + } + if (this.session.status === 'active') { + return RemoteI18n.t('chat.executing'); + } + return this.session.status; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 286e4b22ed..a90edecec2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -1,16 +1,20 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, MUTED } from './Theme'; +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -const SETTINGS_SHEET_BG: string = '#F4F4F7'; -const SETTINGS_ROW_VALUE: string = '#8F8F94'; - @Component export struct SettingsSheet { @Prop generalChatApiUrl: string = ''; @Prop generalChatModelName: string = ''; @Prop hasGeneralChatApiKey: boolean = false; + @Prop generalChatModelCatalog: RemoteModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Prop selectedGeneralChatModelId: string = ''; @Prop accountUsername: string = ''; @Prop authenticatedUserId: string = ''; @Prop deviceId: string = ''; @@ -75,10 +79,11 @@ export struct SettingsSheet { .scrollBar(BarState.Off) Button() { - Image($r('app.media.settings_close_x')) - .width(28) - .height(28) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(21) + .fontColor([INK]) + .width(24) + .height(24) } .width(50) .height(50) @@ -97,7 +102,7 @@ export struct SettingsSheet { } .width('100%') .height('100%') - .backgroundColor(SETTINGS_SHEET_BG) + .backgroundColor(PAGE_BG) .borderRadius({ topLeft: 34, topRight: 34 }) } @@ -108,12 +113,14 @@ export struct SettingsSheet { Column({ space: 2 }) { Text(RemoteI18n.t('remote.settings.profile')) .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) - Text(this.accountUsername || RemoteI18n.t('remote.settings.accountNotSignedIn')) - .fontSize(13).fontColor(SETTINGS_ROW_VALUE) + Text(this.accountUsername || (this.authenticatedUserId.length > 0 ? + RemoteI18n.t('remote.settings.accountSignedIn') : + RemoteI18n.t('remote.settings.accountNotSignedIn'))) + .fontSize(13).fontColor(MUTED) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) }.layoutWeight(1).alignItems(HorizontalAlign.Start) - Image($r('app.media.settings_chevron_right')) - .width(10).height(14).objectFit(ImageFit.Contain).opacity(0.52) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14).fontColor([MUTED]).width(18).height(18).opacity(0.72) } .width('100%').height(64).padding({ left: 18, right: 18 }) .backgroundColor(CARD).borderRadius(8).margin({ bottom: 24 }) @@ -134,7 +141,6 @@ export struct SettingsSheet { ModelServiceCard() { Column() { this.SettingsRow( - $r('app.media.settings_apps_grid'), RemoteI18n.t('settings.modelService.title'), this.modelServiceStatus(), true, @@ -176,7 +182,7 @@ export struct SettingsSheet { Blank() Text(value) .fontSize(15) - .fontColor(SETTINGS_ROW_VALUE) + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -187,12 +193,13 @@ export struct SettingsSheet { } @Builder - SettingsRow(icon: Resource, title: string, value: string, showChevron: boolean, action: () => void) { + SettingsRow(title: string, value: string, showChevron: boolean, action: () => void) { Row({ space: 16 }) { - Image(icon) + SymbolGlyph($r('sys.symbol.square_grid_2x2')) + .fontSize(20) + .fontColor([MUTED]) .width(23) .height(23) - .objectFit(ImageFit.Contain) Text(title) .fontSize(16) .fontWeight(FontWeight.Medium) @@ -200,15 +207,16 @@ export struct SettingsSheet { Blank() Text(value) .fontSize(15) - .fontColor(SETTINGS_ROW_VALUE) + .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .constraintSize({ maxWidth: 130 }) if (showChevron) { - Image($r('app.media.settings_chevron_right')) - .width(10) - .height(14) - .objectFit(ImageFit.Contain) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + .width(18) + .height(18) } } .width('100%') @@ -224,6 +232,8 @@ export struct SettingsSheet { apiUrl: this.savedGeneralChatApiUrl, modelName: this.savedGeneralChatModelName, hasApiKey: this.savedGeneralChatHasApiKey, + modelCatalog: this.generalChatModelCatalog, + selectedModelId: this.selectedGeneralChatModelId, onClose: () => { this.showModelService = false; }, @@ -254,10 +264,29 @@ export struct SettingsSheet { } private modelServiceStatus(): string { - return this.savedGeneralChatApiUrl.length > 0 && - this.savedGeneralChatModelName.length > 0 && this.savedGeneralChatHasApiKey ? - RemoteI18n.t('settings.modelService.configured') : - RemoteI18n.t('settings.modelService.notConfigured'); + const model = this.selectedGeneralChatModel(); + if (model) { + return model.model_name || model.name || model.id; + } + return RemoteI18n.t('settings.modelService.notConfigured'); + } + + private selectedGeneralChatModel(): RemoteModelConfig | undefined { + const candidates = [ + this.selectedGeneralChatModelId, + this.generalChatModelCatalog.session_model_id || '', + this.generalChatModelCatalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + const model = this.generalChatModelCatalog.models.find((item: RemoteModelConfig): boolean => { + return item.id === modelId && item.enabled; + }); + if (model) { + return model; + } + } + return undefined; } private syncGeneralChatConfig(apiUrl: string, modelName: string, hasApiKey: boolean): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets new file mode 100644 index 0000000000..0845df285f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarToggleButton.ets @@ -0,0 +1,49 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarToggleButton { + @Param restore: boolean = false; + @Param controlSize: number = 44; + @Event onToggle: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + Row() { + Text('') + .width(6) + .height(14) + .backgroundColor(INK) + .opacity(0.18) + .borderRadius({ topLeft: 2, bottomLeft: 2 }) + Divider() + .vertical(true) + .height(14) + .color(INK) + .opacity(0.7) + Blank() + .layoutWeight(1) + } + .width(20) + .height(18) + .padding({ left: 2, right: 2 }) + .alignItems(VerticalAlign.Center) + .border({ width: 1.5, color: INK }) + .borderRadius(3) + } + .width(this.controlSize) + .height(this.controlSize) + .backgroundColor(this.restore ? CARD : '#00000000') + .border({ width: this.restore ? 1 : 0, color: LINE }) + .borderRadius(this.restore ? this.controlSize / 2 : 8) + .shadow({ + radius: this.restore ? 14 : 0, + color: this.restore ? '#12000000' : '#00000000', + offsetY: this.restore ? 5 : 0 + }) + .accessibilityText(RemoteI18n.t(this.restore ? 'sidebar.restore' : 'sidebar.collapse')) + .onClick(() => { + this.onToggle(); + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index bc7c28ec1e..227cccd2a2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -8,6 +8,7 @@ export struct StreamingMarkdownContent { @Prop @Watch('handleTextChanged') active: boolean = false; @Prop @Watch('handleTextChanged') streamKey: string = ''; onCopyText: (text: string) => void = (_text: string) => {}; + onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; @State renderedText: string = ''; private targetText: string = ''; private timerId: number = 0; @@ -34,6 +35,9 @@ export struct StreamingMarkdownContent { text: this.renderedText, onCopyText: (_body: string) => { this.onCopyText(this.text); + }, + onOpenLink: (reference: string, label: string) => { + this.onOpenLink(reference, label); } }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 431585d68d..6adc633b73 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -1,10 +1,28 @@ -export const PAGE_BG: string = '#FDFDFB'; -export const INK: string = '#171717'; -export const MUTED: string = '#706F6A'; -export const SUBTLE: string = '#A5A39B'; -export const LINE: string = '#E9E7E2'; -export const CARD: string = '#FFFFFF'; -export const ACCENT: string = '#111111'; -export const SOFT: string = '#F4F3F0'; -export const GREEN: string = '#27C46A'; -export const RED: string = '#E04F4F'; +export const PAGE_BG: ResourceColor = $r('app.color.page_bg'); +export const INK: ResourceColor = $r('app.color.ink'); +export const MUTED: ResourceColor = $r('app.color.muted'); +export const SUBTLE: ResourceColor = $r('app.color.subtle'); +export const LINE: ResourceColor = $r('app.color.line'); +export const CARD: ResourceColor = $r('app.color.card'); +export const ACCENT: ResourceColor = $r('app.color.accent'); +export const FILE_LINK: ResourceColor = $r('app.color.file_link'); +export const PRIMARY_ACTION: ResourceColor = $r('app.color.primary_action'); +export const PRIMARY_ACTION_TEXT: ResourceColor = $r('app.color.primary_action_text'); +export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); +export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); +export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); +export const CONNECT_HERO_SURFACE: ResourceColor = $r('app.color.connect_hero_surface'); +export const SOFT: ResourceColor = $r('app.color.soft'); +export const FLOATING_PANEL_BG: ResourceColor = $r('app.color.floating_panel_bg'); +export const GREEN: ResourceColor = $r('app.color.green'); +export const RED: ResourceColor = $r('app.color.red'); +export const CODE_LINE_NUMBER: ResourceColor = $r('app.color.code_line_number'); +export const CODE_KEYWORD: ResourceColor = $r('app.color.code_keyword'); +export const CODE_STRING: ResourceColor = $r('app.color.code_string'); +export const CODE_NUMBER: ResourceColor = $r('app.color.code_number'); +export const CODE_COMMENT: ResourceColor = $r('app.color.code_comment'); +export const CODE_FUNCTION: ResourceColor = $r('app.color.code_function'); +export const CODE_TYPE: ResourceColor = $r('app.color.code_type'); +export const CODE_CONSTANT: ResourceColor = $r('app.color.code_constant'); +export const CODE_PROPERTY: ResourceColor = $r('app.color.code_property'); +export const CODE_TARGET_BG: ResourceColor = $r('app.color.code_target_bg'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 28bda5663f..bd1316fb2c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,6 +1,7 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, RED } from './Theme'; +import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; +import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; interface QuestionPreview { header?: string; @@ -58,6 +59,8 @@ export struct ToolStatusList { @Event onCancelTool: (toolId: string) => void = (_toolId: string) => {}; @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Event onOpenFilePreview: (path: string, label: string) => void = + (_path: string, _label: string) => {}; @Local questionAnswerToolId: string = ''; @Local questionAnswerText: string = ''; @Local toolInputEditToolId: string = ''; @@ -107,21 +110,23 @@ export struct ToolStatusList { this.ToolStatusIcon(tool) Text(this.toolLineLabel(tool)) .fontSize(13) - .fontColor(this.hasToolError(tool) ? RED : MUTED) + .fontColor(this.hasToolError(tool) ? RED : + (this.toolFilePath(tool).length > 0 ? FILE_LINK : MUTED)) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + .onClick(() => { + if (this.toolFilePath(tool).length > 0) { + this.openToolFile(tool); + } else { + this.toggleToolExpanded(tool, index); + } + }) this.TrailingChevron(tool, index) } .width('100%') .height(28) .alignItems(VerticalAlign.Center) - .onClick(() => { - if (this.canExpandTool(tool)) { - const key = this.toolKey(tool, index); - this.expandedToolKey = this.expandedToolKey === key ? '' : key; - } - }) if (this.canExpandTool(tool) && this.expandedToolKey === this.toolKey(tool, index)) { if (this.toolInputPreview(tool).length > 0) { @@ -139,7 +144,7 @@ export struct ToolStatusList { Row({ space: 8 }) { Text(RemoteI18n.t('chat.approve')) .fontSize(12) - .fontColor(CARD) + .fontColor(PRIMARY_ACTION_TEXT) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) @@ -154,7 +159,7 @@ export struct ToolStatusList { .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { @@ -176,7 +181,7 @@ export struct ToolStatusList { .textAlign(TextAlign.Center) .height(32) .padding({ left: 14, right: 14 }) - .backgroundColor('#F0EFEB') + .backgroundColor(SOFT) .borderRadius(16) .border({ width: 1, color: LINE }) .onClick(() => { @@ -214,7 +219,14 @@ export struct ToolStatusList { @Builder TrailingChevron(tool: ConversationUiToolStatus, index: number) { if (this.canExpandTool(tool)) { - this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') + Stack({ alignContent: Alignment.Center }) { + this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') + } + .width(32) + .height(28) + .onClick(() => { + this.toggleToolExpanded(tool, index); + }) } else { Text('') .width(14) @@ -387,17 +399,17 @@ export struct ToolStatusList { .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) Text('') .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) Text('') .width(3.5) .height(3.5) .borderRadius(2) - .backgroundColor('#2F80ED') + .backgroundColor(ACCENT) } .width(16) .height(16) @@ -494,7 +506,7 @@ export struct ToolStatusList { .fontSize(12) .fontColor(INK) .lineHeight(17) - .backgroundColor('#FBFAF7') + .backgroundColor(SOFT) .borderRadius(14) .padding(10) .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) @@ -539,11 +551,11 @@ export struct ToolStatusList { Row({ space: 8 }) { Text(RemoteI18n.t('chat.submitAnswer')) .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? CARD : MUTED) + .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) .textAlign(TextAlign.Center) .height(32) .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : '#EDEBE6') + .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) .borderRadius(16) .onClick(() => { if (this.canSubmitQuestion(tool.id || '')) { @@ -592,9 +604,9 @@ export struct ToolStatusList { this.isRunningTool(tool); } - private toolRowBg(tool: ConversationUiToolStatus, index: number): string { + private toolRowBg(tool: ConversationUiToolStatus, index: number): ResourceColor { if (this.isEmphasizedToolRow(tool, index)) { - return '#FBFAF7'; + return SOFT; } return '#00000000'; } @@ -1110,108 +1122,53 @@ export struct ToolStatusList { return this.operationLabel(tool); } + private toolFileReference(tool: ConversationUiToolStatus): ToolFileReference | undefined { + return ToolFileReferenceResolver.resolve(tool.name || '', tool.tool_input, tool.input_preview || ''); + } + + private toolFilePath(tool: ConversationUiToolStatus): string { + return this.toolFileReference(tool)?.path || ''; + } + + private openToolFile(tool: ConversationUiToolStatus): void { + const reference = this.toolFileReference(tool); + if (reference) { + this.onOpenFilePreview(reference.path, reference.label); + } + } + + private toggleToolExpanded(tool: ConversationUiToolStatus, index: number): void { + if (!this.canExpandTool(tool)) { + return; + } + const key = this.toolKey(tool, index); + this.expandedToolKey = this.expandedToolKey === key ? '' : key; + } + private canExpandTool(tool: ConversationUiToolStatus): boolean { return this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool) || this.isCompletedTool(tool) || this.isCancelledTool(tool); } - private toolTypeColor(tool: ConversationUiToolStatus): string { - if (this.isQuestionLikeTool(tool)) { - return '#A15C00'; - } - if (this.isTodoTool(tool)) { - return '#21A85A'; - } - if (this.isTaskTool(tool)) { - return '#2D7DDF'; - } - if (this.isGitTool(tool)) { - return '#6C5CE7'; - } + private toolTypeColor(tool: ConversationUiToolStatus): ResourceColor { if (this.isDeleteTool(tool)) { return RED; } - if (this.isDiffTool(tool)) { - return '#7E62D9'; - } - if (this.isPatchTool(tool)) { - return '#C2542D'; - } - if (this.isFileCreateTool(tool)) { - return '#21A85A'; - } - if (this.isFileMutationTool(tool)) { - return '#C2542D'; - } - if (this.isFileReadTool(tool)) { - return '#7E62D9'; - } - if (this.isSearchTool(tool)) { - return '#D28A16'; - } - if (this.isWebTool(tool)) { - return '#2D7DDF'; - } - if (this.isCommandTool(tool)) { - return '#1E7A70'; + if (this.isTodoTool(tool) || this.isFileCreateTool(tool)) { + return GREEN; } return MUTED; } - private toolTypeBg(tool: ConversationUiToolStatus): string { - if (this.isQuestionLikeTool(tool)) { - return '#FFF4DE'; - } - if (this.isTodoTool(tool)) { - return '#EAF7EF'; - } - if (this.isTaskTool(tool)) { - return '#EAF3FF'; - } - if (this.isGitTool(tool)) { - return '#F0EDFF'; - } - if (this.isDeleteTool(tool)) { - return '#FFECEA'; - } - if (this.isDiffTool(tool)) { - return '#F0EDFF'; - } - if (this.isPatchTool(tool)) { - return '#FFF0EA'; - } - if (this.isFileCreateTool(tool)) { - return '#EAF7EF'; - } - if (this.isFileMutationTool(tool)) { - return '#FFF0EA'; - } - if (this.isFileReadTool(tool)) { - return '#F0EDFF'; - } - if (this.isSearchTool(tool)) { - return '#FFF4DE'; - } - if (this.isWebTool(tool)) { - return '#EAF3FF'; - } - if (this.isCommandTool(tool)) { - return '#E8F6F4'; - } - return '#F5F4F0'; + private toolTypeBg(_tool: ConversationUiToolStatus): ResourceColor { + return SOFT; } - private toolTypeBorderColor(tool: ConversationUiToolStatus): string { + private toolTypeBorderColor(tool: ConversationUiToolStatus): ResourceColor { if (this.hasToolError(tool)) { - return '#F0B3AA'; - } - if (this.isPendingConfirmation(tool) || this.isQuestionTool(tool)) { - return '#E5C98E'; - } - if (this.isRunningTool(tool)) { - return '#9FC5F8'; + return RED; } - return '#00000000'; + return LINE; } private toolStatusIcon(tool: ConversationUiToolStatus): string { @@ -1231,20 +1188,10 @@ export struct ToolStatusList { return '•'; } - private toolStatusColor(tool: ConversationUiToolStatus): string { + private toolStatusColor(tool: ConversationUiToolStatus): ResourceColor { if (this.hasToolError(tool)) { return RED; } - const normalized = (tool.status || '').toLowerCase(); - if (normalized === 'running' || normalized === 'active') { - return '#A15C00'; - } - if (normalized === 'pending_confirmation' || normalized === 'needs_confirmation') { - return '#A15C00'; - } - if (normalized === 'cancelled' || normalized === 'canceled' || normalized === 'rejected') { - return MUTED; - } return MUTED; } @@ -1256,24 +1203,12 @@ export struct ToolStatusList { this.isCancelledTool(tool); } - private summaryTypeColor(entry: ToolRenderEntry): string { - if (entry.searchCount > 0 && entry.readCount === 0) { - return '#D28A16'; - } - if (entry.readCount > 0 && entry.searchCount === 0) { - return '#7E62D9'; - } + private summaryTypeColor(_entry: ToolRenderEntry): ResourceColor { return MUTED; } - private summaryTypeBg(entry: ToolRenderEntry): string { - if (entry.searchCount > 0 && entry.readCount === 0) { - return '#FFF4DE'; - } - if (entry.readCount > 0 && entry.searchCount === 0) { - return '#F0EDFF'; - } - return '#F5F4F0'; + private summaryTypeBg(_entry: ToolRenderEntry): ResourceColor { + return SOFT; } private toolPreview(preview: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets index 70952627aa..23f70cddcf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/host/AppRootHostAdapter.ets @@ -1,8 +1,11 @@ +import { common } from '@kit.AbilityKit'; + export interface AppRootHostPort { attach(context: Context, uiContext: UIContext): void; context(): Context; animate(duration: number, callback: () => void): void; showToast(message: string, duration: number): boolean; + openExternalLink?(link: string): Promise; } export class ArkUiAppRootHostAdapter implements AppRootHostPort { @@ -34,4 +37,17 @@ export class ArkUiAppRootHostAdapter implements AppRootHostPort { return false; } } + + async openExternalLink(link: string): Promise { + const value = link.trim(); + if (!/^https?:\/\//i.test(value)) { + return false; + } + try { + await (this.hostContext as common.UIAbilityContext).openLink(value); + return true; + } catch (_err) { + return false; + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets index eac6552399..c4f46a32bb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets @@ -20,13 +20,15 @@ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, GeneralChatConfigUpdate, - GeneralChatConfigValidator + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy } from '../../services/general-chat/GeneralChatConfigStore'; import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; import { GeneralChatServiceState, @@ -49,6 +51,8 @@ import { RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; +import { RemoteFilePreviewController } from '../../services/RemoteFilePreviewController'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteModelController } from '../../services/RemoteModelController'; import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; @@ -72,6 +76,7 @@ import { AppRootPresentation, AppRootPresentationActions, ConnectPresentationActions, + FilePreviewPresentationActions, RemoteCreatePresentationActions, RemoteHomePresentationActions, SettingsPresentationActions, @@ -100,6 +105,8 @@ import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; import { RemoteCreateSessionState } from './RemoteCreateSessionState'; import { ConversationViewModel } from './ConversationViewModel'; +import { FilePreviewState } from './FilePreviewState'; +import { FilePreviewRequest, FilePreviewTargetContext } from './FilePreviewTarget'; import { RemoteWorkspaceViewModel, RemoteWorkspaceViewModelHooks @@ -141,6 +148,9 @@ export class AppRootRuntime { new RemoteWorkspaceCoordinator(this.workspaceRepository); readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly filePreviewState: FilePreviewState = new FilePreviewState(); + private controlTargetEpoch: number = 1; + private remoteCreateWorkspaceLoadVersion: number = 0; readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); @@ -256,7 +266,7 @@ export class AppRootRuntime { await this.reconnectActiveRemote(); }, async (session: SessionSummary): Promise => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); await this.loadActiveMessages(); } ) @@ -333,7 +343,7 @@ export class AppRootRuntime { this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); }, onActiveSession: (session: SessionSummary) => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); }, onStatusText: (statusText: string) => { this.setRemoteStatusText(statusText); @@ -399,7 +409,7 @@ export class AppRootRuntime { this.syncChatTimelineFromStore(); }, onActiveSession: (session: SessionSummary) => { - this.remotePageState.setActiveSession(session); + this.applyRemoteActiveSession(session); }, onSessionTitleChanged: (sessionId: string, title: string) => { this.remoteSessionController.updateSessionTitle(sessionId, title); @@ -431,6 +441,13 @@ export class AppRootRuntime { this.setRemoteBusy(isBusy); } ); + readonly remoteFilePreviewController: RemoteFilePreviewController = + new RemoteFilePreviewController( + this.sessionManager, + this.filePreviewState, + (): boolean => RemoteUiState.canUseRemote(this.connectionState), + (): number => this.controlTargetEpoch + ); readonly remoteToolActionController: RemoteToolActionController = new RemoteToolActionController( this.sessionManager, @@ -450,7 +467,7 @@ export class AppRootRuntime { { canPoll: (sessionId: string) => { return this.activeSession.sessionId === sessionId && - this.isRoute(AppRoute.RemoteChat) && + this.isRemoteConversationContext(sessionId) && this.ensureRemoteAvailable(); }, onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { @@ -496,7 +513,7 @@ export class AppRootRuntime { (): boolean => this.isBusy, (busy: boolean): void => this.setRemoteBusy(busy), (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.replaceRoute(AppRoute.RemoteHome), + (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), (): void => this.stopPolling(), (): void => this.startPolling(), (sessionId: string): void => this.resetChatTimeline(sessionId), @@ -511,7 +528,7 @@ export class AppRootRuntime { sessionId, this.ensureRemoteAvailable(), (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); } ); }, @@ -520,7 +537,7 @@ export class AppRootRuntime { await this.remoteChatCommandController.loadMessages( sessionId, (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); } ); }, @@ -576,7 +593,7 @@ export class AppRootRuntime { async (): Promise => { await this.loadRecentWorkspacesInBackground(); }, - (route: AppRoute): void => this.replaceRoute(route), + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), (): void => this.appShellState.setConnectSheetVisible(false), (): void => this.appShellState.setConnectSheetVisible(true) ); @@ -614,6 +631,7 @@ export class AppRootRuntime { async (id: string): Promise => { await this.selectModel(id); }, async (): Promise => { await this.pickImages(); }, (id: string): void => this.removeSelectedImage(id), + (route: AppRoute, request: FilePreviewRequest): void => this.openFilePreview(route, request), (path: string): void => this.downloadVisibleFile(path), async (): Promise => { await this.sendVisibleChatMessage(); }, async (): Promise => { await this.toggleVoiceInput(); }, @@ -624,6 +642,7 @@ export class AppRootRuntime { (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), (): void => this.closeAppSidebar(), (source: ConversationSource): void => { this.switchWideConversationSource(source); }, + (): void => this.enterCompactLayout(), new RemoteHomePresentationActions( (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, @@ -634,9 +653,13 @@ export class AppRootRuntime { (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, - (agentType: string): void => { this.createSession(agentType); }, (): void => { this.openRemoteCreateSession(); }, + (agentType: string): void => { this.createSession(agentType); }, + (agentType: string): void => { this.createSession(agentType, true); }, + (): void => { this.openRemoteCreateSession(); }, (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, + (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType, true); }, (session: RemoteSession): void => this.openHomeSession(session), + (session: RemoteSession): void => this.openHomeSessionInPlace(session), (session: RemoteSession): void => { this.deleteHomeSession(session); } ), new RemoteCreatePresentationActions( @@ -663,12 +686,11 @@ export class AppRootRuntime { (session: RemoteSession): void => { this.deleteHomeSession(session); } ), new SettingsPresentationActions( - (): void => this.appShellState.setSettingsVisible(false), + (): void => this.appShellState.leaveSettings(), (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, (): void => { this.reconnect(); }, (): void => { - this.appShellState.setSettingsVisible(false); - setTimeout(() => this.appShellState.openSettings('account'), 180); + this.appShellState.openSettings('account'); }, (relayUrl: string, username: string, password: string): Promise => this.loginCloudAccount(relayUrl, username, password), @@ -688,7 +710,7 @@ export class AppRootRuntime { // Keep connection progress on the same RemoteHome surface as the // connected state instead of showing a separate loading sheet. this.appShellState.setConnectSheetVisible(false); - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); this.connect(false, password || ''); }, (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, @@ -700,6 +722,12 @@ export class AppRootRuntime { (): Promise => this.listCloudAccountDevices(), (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) ), + new FilePreviewPresentationActions( + (): void => this.closeFilePreview(), + (): void => this.refreshFilePreview(), + (path: string): void => this.downloadVisibleFile(path), + (reference: string, label: string): void => this.openFilePreviewLink(reference, label) + ), (): string => this.generalChatHomeStatusText() ); readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; @@ -732,6 +760,7 @@ export class AppRootRuntime { await this.generalChatBootstrapController.restore(this.host.context()); await this.cloudAccountSessionStore.init(this.host.context()); await this.restoreCloudAccountSession(); + await this.refreshGeneralChatModelCatalog(); await this.restoreIdentity(); } @@ -756,6 +785,7 @@ export class AppRootRuntime { this.stopGeneralChatStream(true, 'failed'); this.generalChatDraftLifecycleController.cancel(); this.remoteFileDownloadController.cancel(); + this.remoteFilePreviewController.close(); this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { this.setAllVoiceListening(false); }); @@ -845,6 +875,7 @@ export class AppRootRuntime { } private routeCreatedRemoteSession(sessionId: string): void { + this.closeFilePreview(); if (this.isRoute(AppRoute.RemoteCreate)) { this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); return; @@ -852,7 +883,26 @@ export class AppRootRuntime { this.pushRoute(AppRoute.RemoteChat, sessionId); } + private routeRemoteSessionInPlace(_sessionId: string): void { + this.closeFilePreview(); + if (this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat)) { + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + + private isRemoteConversationContext(sessionId: string): boolean { + if (sessionId.length === 0 || this.activeSession.sessionId !== sessionId) { + return false; + } + return this.isRoute(AppRoute.RemoteChat) || this.isRoute(AppRoute.RemoteHome); + } + handleNavigationBack(route: AppRoute): boolean { + if (this.filePreviewState.visible) { + this.closeFilePreview(); + return true; + } const action = this.appShellViewModel.backAction(route); if (action === AppNavigationBackAction.CloseSidebar) { this.closeAppSidebar(); @@ -869,6 +919,14 @@ export class AppRootRuntime { return false; } + handleRootBack(): boolean { + if (!this.filePreviewState.visible) { + return false; + } + this.closeFilePreview(); + return true; + } + handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { this.conversationIntentDispatcher.dispatch(route, intent); @@ -898,8 +956,13 @@ export class AppRootRuntime { return probeError; } } + const catalogBeforeSave = await this.generalChatConfigStore.modelCatalog(); const snapshot = await this.generalChatConfigStore.save(update); + if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { + await this.generalChatConfigStore.selectLocalModel(); + } this.applyGeneralChatConfig(snapshot); + await this.refreshGeneralChatModelCatalog(); return ''; } catch (err) { return ConnectionErrorPolicy.errorText(err); @@ -970,6 +1033,16 @@ export class AppRootRuntime { ); } + private async refreshGeneralChatModelCatalog(): Promise { + const catalog = await this.generalChatConfigStore.modelCatalog(); + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + this.generalChatPageState.setModelCatalog(catalog, selectedModelId); + const active = await this.generalChatConfigStore.activeSnapshot(); + this.generalChatPageState.setServiceState( + GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) + ); + } + async restoreIdentity(): Promise { if (this.remotePageState.controlTargetType === 'account_device') { return; @@ -1016,6 +1089,7 @@ export class AppRootRuntime { } async disconnect(clearPairing: boolean): Promise { + this.invalidateFilePreviewTarget(); await this.remoteConnectionViewModel.disconnect(clearPairing); } @@ -1099,10 +1173,12 @@ export class AppRootRuntime { } async selectWorkspace(path: string): Promise { + this.closeFilePreview(); await this.remoteWorkspaceViewModel.selectWorkspace(path); } async selectAssistant(path: string): Promise { + this.closeFilePreview(); await this.remoteWorkspaceViewModel.selectAssistant(path); } @@ -1155,7 +1231,7 @@ export class AppRootRuntime { } if (RemoteUiState.canUseRemote(this.connectionState)) { this.appShellState.setConnectSheetVisible(false); - this.pushRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); return; } this.appShellState.setConnectSheetVisible(true); @@ -1180,14 +1256,23 @@ export class AppRootRuntime { RemoteUiState.canUseRemote(this.connectionState), activeRemoteSessionId ); - const targetSessionId = target.hasSessionParam() ? target.routeParam().sessionId : ''; - this.appShellViewModel.replaceRouteWithoutAnimation(target.name, targetSessionId); - if (target.name === AppRoute.RemoteChat) { + const hasActiveRemoteConversation = target.name === AppRoute.RemoteChat; + this.appShellViewModel.replaceRouteWithoutAnimation( + hasActiveRemoteConversation ? AppRoute.RemoteHome : target.name + ); + if (hasActiveRemoteConversation) { this.startPolling(); await this.loadActiveMessages(); } } + private enterCompactLayout(): void { + const sessionId = this.remotePageState.activeSession.sessionId || ''; + if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { + this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); + } + } + openAddConnection(): void { this.appShellState.setConnectSheetVisible(true); } @@ -1213,6 +1298,7 @@ export class AppRootRuntime { relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, masterKey: Encoding.bytesToBase64(session.masterKey) }); + await this.loadGeneralChatAccountModels(session, relayUrl); RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); RemoteLogger.info(`cloud account login success user=${session.userId}`); return session.userId; @@ -1228,12 +1314,33 @@ export class AppRootRuntime { masterKey: Encoding.base64ToBytes(persisted.masterKey) }; this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + await this.loadGeneralChatAccountModels(session, persisted.relayUrl); } catch (err) { RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); await this.cloudAccountSessionStore.clear(); } } + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { + this.generalChatConfigStore.replaceAccountModels([]); + try { + const blob = await this.cloudAccountClient.fetchSettings(relayUrl, session); + if (!blob) { + this.generalChatConfigStore.replaceAccountModels([]); + await this.refreshGeneralChatModelCatalog(); + RemoteLogger.info('cloud model catalog is empty'); + return; + } + const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); + this.generalChatConfigStore.replaceAccountModels(models); + await this.refreshGeneralChatModelCatalog(); + RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); + } catch (err) { + await this.refreshGeneralChatModelCatalog(); + RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + async syncCloudAccount(): Promise { if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); @@ -1248,6 +1355,7 @@ export class AppRootRuntime { } throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); } + await this.loadGeneralChatAccountModels(this.cloudAccountSession, this.cloudAccountRelayUrl); RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); return String(bundles.length); } @@ -1260,6 +1368,7 @@ export class AppRootRuntime { } async logoutCloudAccount(): Promise { + this.invalidateFilePreviewTarget(); if (this.remotePageState.controlTargetType === 'account_device') { this.remoteActivityViewModel.invalidate(); this.stopPolling(); @@ -1273,6 +1382,8 @@ export class AppRootRuntime { } this.cloudAccountSession = undefined; this.cloudAccountRelayUrl = ''; + this.generalChatConfigStore.replaceAccountModels([]); + await this.refreshGeneralChatModelCatalog(); await this.cloudAccountSessionStore.clear(); this.remotePageState.setAccountUserId(''); this.remotePageState.setAccountUsername(''); @@ -1341,6 +1452,7 @@ export class AppRootRuntime { } private async expireCloudAccountSession(): Promise { + this.invalidateFilePreviewTarget(); this.cloudAccountSession = undefined; this.cloudAccountRelayUrl = ''; await this.cloudAccountSessionStore.clear(); @@ -1383,10 +1495,11 @@ export class AppRootRuntime { this.connectionState === ConnectionState.Connected) { this.appShellState.setConnectSheetVisible(false); if (navigateHome) { - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); } return; } + this.invalidateFilePreviewTarget(); this.remoteActivityViewModel.invalidate(); this.remoteConnectionCoordinator.invalidate(); this.stopPolling(); @@ -1428,7 +1541,7 @@ export class AppRootRuntime { this.appShellState.setSettingsVisible(false); this.appShellState.setConnectSheetVisible(false); if (navigateHome) { - this.replaceRoute(AppRoute.RemoteHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); } await this.cloudAccountSessionStore.save({ relayUrl: this.cloudAccountRelayUrl, @@ -1463,6 +1576,7 @@ export class AppRootRuntime { } openHomeSession(session: RemoteSession): void { + this.closeFilePreview(); if (session.agentType === 'chat') { this.openGeneralSession(session); return; @@ -1470,6 +1584,15 @@ export class AppRootRuntime { this.openSession(session); } + openHomeSessionInPlace(session: RemoteSession): void { + this.closeFilePreview(); + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.openSession(session, true); + } + async deleteHomeSession(session: RemoteSession): Promise { if (session.agentType !== 'chat') { await this.deleteSession(session); @@ -1528,8 +1651,8 @@ export class AppRootRuntime { async (sessionId: string): Promise => { return this.generalChatDraftLifecycleController.restore(sessionId); }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); + (_sessionId: string) => { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } ); } @@ -1548,8 +1671,8 @@ export class AppRootRuntime { async (): Promise => { await this.generalChatDraftLifecycleController.clearHomeNow(); }, - (sessionId: string) => { - this.replaceRoute(AppRoute.ChatHome, sessionId); + (_sessionId: string) => { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } ); if (!created) { @@ -1579,6 +1702,7 @@ export class AppRootRuntime { } closeActiveChat(): void { + this.closeFilePreview(); this.stopVoiceInput(false); if (this.isRoute(AppRoute.GeneralChat)) { this.persistVisibleGeneralChatDraft(); @@ -1626,8 +1750,63 @@ export class AppRootRuntime { this.downloadFile(path); } - async createSession(agentType: string): Promise { - await this.remoteSessionViewModel.createSession(agentType); + openFilePreview(route: AppRoute, request: FilePreviewRequest): void { + const context = new FilePreviewTargetContext( + this.remotePageState.activeSession.sessionId, + this.remotePageState.activeSession.workspacePath || this.remotePageState.workspacePath, + this.controlTargetEpoch + ); + const resolution = FileTargetResolver.resolve(request.reference, request.label, context); + if (resolution.kind === FileReferenceKind.HttpUrl) { + void this.openExternalLink(route, request.reference); + return; + } + if (route !== AppRoute.RemoteChat) { + this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); + return; + } + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { + return; + } + void this.remoteFilePreviewController.open(resolution.target); + } + + private async openExternalLink(route: AppRoute, reference: string): Promise { + const opened = this.host.openExternalLink ? await this.host.openExternalLink(reference) : false; + if (!opened) { + if (AppRouteContract.isGeneralComposerRoute(route)) { + this.generalChatPageState.setStatus(RemoteI18n.t('errors.operationFailed')); + } else { + this.setRemoteStatusText(RemoteI18n.t('errors.operationFailed')); + } + } + } + + closeFilePreview(): void { + this.remoteFilePreviewController.close(); + } + + refreshFilePreview(): void { + void this.remoteFilePreviewController.refresh(); + } + + openFilePreviewLink(reference: string, label: string): void { + this.openFilePreview(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + } + + invalidateFilePreviewTarget(): void { + this.controlTargetEpoch += 1; + this.remoteFilePreviewController.close(); + } + + async createSession(agentType: string, inPlace: boolean = false): Promise { + this.closeFilePreview(); + await this.remoteSessionViewModel.createSession( + agentType, + '', + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); } openRemoteCreateSession(): void { @@ -1650,6 +1829,7 @@ export class AppRootRuntime { } closeRemoteCreateSession(): void { + this.remoteCreateWorkspaceLoadVersion += 1; this.remoteCreateState.closeMenu(); this.popRoute(AppRoute.RemoteHome); } @@ -1694,11 +1874,21 @@ export class AppRootRuntime { } async loadRemoteCreateWorkspaces(): Promise { + const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; + const deviceId = this.remoteCreateState.selectedDeviceId; this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; try { const workspaces = await this.workspaceCoordinator.recentWorkspaces(); + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreateState.selectedDeviceId) { + return; + } this.remoteCreateState.setWorkspaces(workspaces); } catch (err) { + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreateState.selectedDeviceId) { + return; + } this.remoteCreateState.setWorkspaces([]); this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); } @@ -1748,15 +1938,25 @@ export class AppRootRuntime { if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { return; } + const context = this.remoteCreateState.submissionContext(); + const activeDeviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; + if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { + this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceMismatch'); + return; + } this.remoteCreateState.isSubmitting = true; this.remoteCreateState.errorText = ''; this.remoteCreateState.closeMenu(); - const workspacePath = this.remoteCreateState.selectedWorkspacePath; try { - if (workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace(workspacePath, this.workspacePath, instruction); + if (context.workspacePath.length > 0) { + await this.remoteSessionViewModel.createSessionInWorkspace( + context.workspacePath, + this.workspacePath, + instruction, + context.agentType + ); } else { - await this.remoteSessionViewModel.createSession('Claw', instruction); + await this.remoteSessionViewModel.createSession(context.agentType, instruction); } if (this.isRoute(AppRoute.RemoteCreate)) { this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); @@ -1769,8 +1969,20 @@ export class AppRootRuntime { } } - async createSessionInWorkspace(path: string, agentType: string = 'code'): Promise { - await this.remoteSessionViewModel.createSessionInWorkspace(path, this.workspacePath, '', agentType); + async createSessionInWorkspace( + path: string, + agentType: string = 'code', + inPlace: boolean = false + ): Promise { + this.closeFilePreview(); + await this.remoteSessionViewModel.createSessionInWorkspace( + path, + this.workspacePath, + '', + agentType, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); } applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { @@ -1794,8 +2006,23 @@ export class AppRootRuntime { return merged; } - async openSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.openSession(item, this.workspacePath); + async openSession(item: RemoteSession, inPlace: boolean = false): Promise { + this.closeFilePreview(); + await this.remoteSessionViewModel.openSession( + item, + this.workspacePath, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + applyRemoteActiveSession(session: SessionSummary): void { + const current = this.remotePageState.activeSession; + if (this.filePreviewState.visible && + (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { + this.closeFilePreview(); + } + this.remotePageState.setActiveSession(session); } async deleteSession(item: RemoteSession): Promise { @@ -1804,17 +2031,23 @@ export class AppRootRuntime { async loadActiveMessages(): Promise { await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); }); } async loadModelCatalog(sessionId: string): Promise { await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.activeSession.sessionId === activeSessionId && this.isRoute(AppRoute.RemoteChat); + return this.isRemoteConversationContext(activeSessionId); }); } async selectModel(modelId: string): Promise { + if (this.isGeneralChatVisible()) { + if (await this.generalChatConfigStore.selectModel(modelId)) { + await this.refreshGeneralChatModelCatalog(); + } + return; + } await this.remoteSessionViewModel.selectModel(modelId); } @@ -1946,7 +2179,7 @@ export class AppRootRuntime { this.generalChatPageState.clearComposer(); this.generalChatPageState.clearActiveSession(); this.resetGeneralChatTimeline(''); - this.replaceRoute(AppRoute.ChatHome); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); } onVisibleChatInputChange(route: AppRoute, value: string): void { @@ -2164,7 +2397,7 @@ export class AppRootRuntime { } applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (snapshot.sessionId !== this.activeSession.sessionId || !this.isRoute(AppRoute.RemoteChat)) { + if (!this.isRemoteConversationContext(snapshot.sessionId)) { return; } this.chatTimelineStore.applySnapshot(snapshot); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index b504065398..2f79fe98ed 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -2,9 +2,9 @@ export class AppShellState { @Trace showSidebar: boolean = false; @Trace showSettings: boolean = false; - @Trace showAccount: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; @@ -13,14 +13,28 @@ export class AppShellState { setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { - this.showAccount = false; + this.accountReturnMode = ''; } } openSettings(mode: string): void { + if (mode === 'account') { + this.accountReturnMode = this.showSettings ? this.settingsMode : ''; + } else { + this.accountReturnMode = ''; + } this.settingsMode = mode; - this.showSettings = mode !== 'account'; - this.showAccount = mode === 'account'; + this.showSettings = true; + } + + leaveSettings(): void { + if (this.settingsMode === 'account' && this.accountReturnMode.length > 0) { + this.settingsMode = this.accountReturnMode; + this.accountReturnMode = ''; + this.showSettings = true; + return; + } + this.setSettingsVisible(false); } setConnectSheetVisible(visible: boolean): void { @@ -30,7 +44,7 @@ export class AppShellState { closeGlobalSurfaces(): void { this.showSidebar = false; this.showSettings = false; - this.showAccount = false; + this.accountReturnMode = ''; this.showConnectSheet = false; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets index a656908488..0f182e27ef 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets @@ -47,6 +47,9 @@ export class AppShellViewModel { } replaceRouteWithoutAnimation(route: AppRoute, sessionId: string = ''): void { + if (this.currentRoute() === route && sessionId.length === 0) { + return; + } this.navigationStack.clear(false); if (route !== AppRoute.ChatHome) { this.pushRoute(route, sessionId, false); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets index 44a277409d..0c2c8bbf7e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets @@ -2,6 +2,7 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteMo import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from './FilePreviewTarget'; export class ConversationIntentDispatcherHooks { readonly openSidebar: () => void; @@ -29,6 +30,7 @@ export class ConversationIntentDispatcherHooks { readonly selectModel: (modelId: string) => Promise; readonly pickImages: () => Promise; readonly removeImage: (id: string) => void; + readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void; readonly downloadFile: (path: string) => void; readonly send: () => Promise; readonly voiceInput: () => Promise; @@ -45,7 +47,8 @@ export class ConversationIntentDispatcherHooks { reject: (id: string) => Promise, cancel: (id: string) => Promise, answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, - pickImages: () => Promise, removeImage: (id: string) => void, downloadFile: (path: string) => void, + pickImages: () => Promise, removeImage: (id: string) => void, + openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void, downloadFile: (path: string) => void, send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void ) { this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; @@ -56,7 +59,8 @@ export class ConversationIntentDispatcherHooks { this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; - this.downloadFile = downloadFile; this.send = send; this.voiceInput = voiceInput; this.inputChanged = inputChanged; + this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; + this.voiceInput = voiceInput; this.inputChanged = inputChanged; } } @@ -101,6 +105,8 @@ export class ConversationIntentDispatcher { case ConversationIntentType.SelectModel: void this.hooks.selectModel(intent.value); return; case ConversationIntentType.PickImages: void this.hooks.pickImages(); return; case ConversationIntentType.RemoveImage: this.hooks.removeImage(intent.value); return; + case ConversationIntentType.OpenFilePreview: + if (intent.filePreviewRequest) this.hooks.openFilePreview(route, intent.filePreviewRequest); return; case ConversationIntentType.DownloadFile: this.hooks.downloadFile(intent.value); return; case ConversationIntentType.Send: void this.hooks.send(); return; case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets index d238bc58d7..f6a011f6d8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets @@ -14,19 +14,25 @@ export class ConversationLayoutGeometry { readonly isExtraWide: boolean; readonly detailContentOffset: number; readonly detailContentWidth: number; + readonly collapsedDetailContentOffset: number; + readonly collapsedDetailContentWidth: number; constructor( masterPaneWidth: number, masterDetailGap: number, isExtraWide: boolean, detailContentOffset: number, - detailContentWidth: number + detailContentWidth: number, + collapsedDetailContentOffset: number, + collapsedDetailContentWidth: number ) { this.masterPaneWidth = masterPaneWidth; this.masterDetailGap = masterDetailGap; this.isExtraWide = isExtraWide; this.detailContentOffset = detailContentOffset; this.detailContentWidth = detailContentWidth; + this.collapsedDetailContentOffset = collapsedDetailContentOffset; + this.collapsedDetailContentWidth = collapsedDetailContentWidth; } } @@ -41,28 +47,38 @@ class ConversationLayoutSegment { } export class ConversationLayoutPolicy { + static readonly TABLET_DEVICE_TYPE: string = 'tablet'; static readonly WIDE_LAYOUT_MIN_WIDTH: number = 720; static readonly FALLBACK_MASTER_PANE_WIDTH: number = 344; static readonly MIN_MASTER_PANE_WIDTH: number = 280; static readonly MIN_DETAIL_PANE_WIDTH: number = 360; static readonly EXTRA_WIDE_MIN_WIDTH: number = 1080; - static useMasterDetail(viewportWidth: number, mediaQueryMatched: boolean, isFolded: boolean): boolean { + static useMasterDetail( + viewportWidth: number, + mediaQueryMatched: boolean, + isFolded: boolean, + deviceType: string, + creases: ConversationLayoutCrease[] + ): boolean { if (isFolded) { return false; } - return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; + if (!ConversationLayoutPolicy.hasWideViewport(viewportWidth, mediaQueryMatched)) { + return false; + } + const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); + if (visibleCreases.length > 0) { + return visibleCreases.length >= 2; + } + return ConversationLayoutPolicy.isTabletDevice(deviceType); } static resolveWideGeometry( viewportWidth: number, creases: ConversationLayoutCrease[] ): ConversationLayoutGeometry { - const visibleCreases = creases - .filter((crease: ConversationLayoutCrease): boolean => { - return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; - }) - .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + const visibleCreases = ConversationLayoutPolicy.visibleCreases(viewportWidth, creases); const firstCrease = visibleCreases.find((crease: ConversationLayoutCrease): boolean => { return crease.left >= ConversationLayoutPolicy.MIN_MASTER_PANE_WIDTH && crease.left + crease.width <= viewportWidth - ConversationLayoutPolicy.MIN_DETAIL_PANE_WIDTH; @@ -73,12 +89,17 @@ export class ConversationLayoutPolicy { const detailStart = masterPaneWidth + masterDetailGap; const detailSegments = ConversationLayoutPolicy.detailSegments(viewportWidth, detailStart, visibleCreases); const contentSegment = ConversationLayoutPolicy.widestSegment(detailSegments); + const collapsedContentSegment = ConversationLayoutPolicy.widestSegment( + ConversationLayoutPolicy.detailSegments(viewportWidth, 0, visibleCreases) + ); return new ConversationLayoutGeometry( masterPaneWidth, masterDetailGap, visibleCreases.length > 1 || viewportWidth >= ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, contentSegment ? contentSegment.left - detailStart : 0, - contentSegment ? contentSegment.width : 0 + contentSegment ? contentSegment.width : 0, + collapsedContentSegment ? collapsedContentSegment.left : 0, + collapsedContentSegment ? collapsedContentSegment.width : 0 ); } @@ -113,4 +134,23 @@ export class ConversationLayoutPolicy { return widest; }, undefined); } + + private static hasWideViewport(viewportWidth: number, mediaQueryMatched: boolean): boolean { + return mediaQueryMatched || viewportWidth >= ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH; + } + + private static isTabletDevice(deviceType: string): boolean { + return deviceType.toLowerCase() === ConversationLayoutPolicy.TABLET_DEVICE_TYPE; + } + + private static visibleCreases( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): ConversationLayoutCrease[] { + return creases + .filter((crease: ConversationLayoutCrease): boolean => { + return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; + }) + .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets new file mode 100644 index 0000000000..9d60fafb3e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets @@ -0,0 +1,82 @@ +import { + ConversationUiModel, + ConversationUiModelCatalog +} from '../components/ConversationUiModels'; + +export class ConversationModelPresentationPolicy { + static enabledModels(catalog: ConversationUiModelCatalog): ConversationUiModel[] { + return catalog.models.filter((model: ConversationUiModel) => model.enabled); + } + + static selectedModel( + catalog: ConversationUiModelCatalog, + selectedModelId: string + ): ConversationUiModel | undefined { + const candidates = [ + selectedModelId, + catalog.session_model_id || '', + catalog.default_models.primary || '' + ]; + for (let index = 0; index < candidates.length; index += 1) { + const modelId = candidates[index]; + if (modelId.length === 0) { + continue; + } + const model = catalog.models.find((item: ConversationUiModel) => item.id === modelId && item.enabled); + if (model) { + return model; + } + } + return undefined; + } + + static primaryLabel(model: ConversationUiModel, fallback: string): string { + const modelName = ConversationModelPresentationPolicy.cleanLabel(model.model_name || ''); + if (ConversationModelPresentationPolicy.isSpecificLabel(modelName)) { + return modelName; + } + const name = ConversationModelPresentationPolicy.cleanLabel(model.name || ''); + if (ConversationModelPresentationPolicy.isSpecificLabel(name)) { + return name; + } + const id = ConversationModelPresentationPolicy.cleanLabel(model.id || ''); + return id.length > 0 ? id : fallback; + } + + static secondaryLabel(model: ConversationUiModel, fallback: string): string { + const provider = ConversationModelPresentationPolicy.cleanLabel(model.provider || ''); + const name = ConversationModelPresentationPolicy.cleanLabel(model.name || ''); + const primary = ConversationModelPresentationPolicy.primaryLabel(model, fallback); + if (provider.length > 0 && name.length > 0 && name !== primary && name !== provider) { + return `${provider} · ${name}`; + } + if (provider.length > 0 && provider !== primary) { + return provider; + } + if (name.length > 0 && name !== primary) { + return name; + } + return model.id || primary; + } + + private static cleanLabel(value: string): string { + const trimmed = (value || '').trim(); + if (trimmed.length === 0) { + return ''; + } + const withoutScheme = trimmed.replace(/^openbitfun[:/_-]+/i, '').replace(/^anthropic[:/_-]+/i, ''); + const parts = withoutScheme.split(/[/:]/).filter((part: string) => part.length > 0); + return parts.length > 0 ? parts[parts.length - 1] : withoutScheme; + } + + private static isSpecificLabel(label: string): boolean { + const normalized = label.toLowerCase(); + return label.length > 0 && + normalized !== 'openbitfun' && + normalized !== 'anthropic' && + normalized !== 'openai' && + normalized !== 'google' && + normalized !== 'azure' && + normalized !== 'bitfun'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets new file mode 100644 index 0000000000..0ab0183dd2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets @@ -0,0 +1,51 @@ +import { RemoteSession } from '../../model/RemoteModels'; + +export class ConversationSessionFilterPolicy { + static matches( + session: RemoteSession, + query: string, + fallbackWorkspacePath: string, + workspaceFilter: string, + agentFilter: string, + statusFilter: string, + assistantSession: boolean + ): boolean { + if (session.id.length === 0 || session.status === 'archived') { + return false; + } + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length > 0 && session.title.toLowerCase().indexOf(normalizedQuery) < 0) { + return false; + } + const workspacePath = session.workspacePath || (assistantSession ? '' : fallbackWorkspacePath); + if (workspaceFilter.length > 0 && + !ConversationSessionFilterPolicy.workspacePathsEqual(workspacePath, workspaceFilter)) { + return false; + } + if (agentFilter.length > 0 && ConversationSessionFilterPolicy.agentGroup(session, assistantSession) !== agentFilter) { + return false; + } + const status = (session.status || '').trim().toLowerCase(); + return statusFilter.length === 0 || status === statusFilter; + } + + static agentGroup(session: RemoteSession, assistantSession: boolean): string { + if (assistantSession) { + return 'chat'; + } + return (session.agentType || '').toLowerCase() === 'cowork' ? 'cowork' : 'code'; + } + + static workspacePathsEqual(left: string, right: string): boolean { + return ConversationSessionFilterPolicy.normalizeWorkspacePath(left) === + ConversationSessionFilterPolicy.normalizeWorkspacePath(right); + } + + private static normalizeWorkspacePath(path: string): string { + let value = path.trim(); + while (value.length > 1 && (value.endsWith('/') || value.endsWith('\\'))) { + value = value.slice(0, value.length - 1); + } + return value; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 06563fa70d..a7db3241bc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -99,6 +99,8 @@ export class ConversationViewState { state.hasMoreMessages = general.hasMoreMessages; state.timelineItems = general.timelineItems; state.timelineRevision = general.timelineRevision; + state.modelCatalog = toConversationUiModelCatalog(general.modelCatalog); + state.selectedModelId = general.selectedModelId; state.isSessionPinned = general.activeSession.sessionId.length > 0 && general.pinnedSessionId() === general.activeSession.sessionId; state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets new file mode 100644 index 0000000000..c7da144bda --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets @@ -0,0 +1,185 @@ +import { ConversationLayoutCrease } from './ConversationLayoutPolicy'; + +export enum FilePreviewPlacement { + Hidden = 'hidden', + CompactFullPage = 'compact_full_page', + WideFocusSplit = 'wide_focus_split', + WideTriplePane = 'wide_triple_pane' +} + +export class FilePreviewLayout { + readonly placement: FilePreviewPlacement; + readonly masterPaneWidth: number; + readonly masterConversationGap: number; + readonly conversationPaneWidth: number; + readonly conversationPreviewGap: number; + readonly previewPaneWidth: number; + + constructor( + placement: FilePreviewPlacement, + masterPaneWidth: number = 0, + masterConversationGap: number = 0, + conversationPaneWidth: number = 0, + conversationPreviewGap: number = 0, + previewPaneWidth: number = 0 + ) { + this.placement = placement; + this.masterPaneWidth = masterPaneWidth; + this.masterConversationGap = masterConversationGap; + this.conversationPaneWidth = conversationPaneWidth; + this.conversationPreviewGap = conversationPreviewGap; + this.previewPaneWidth = previewPaneWidth; + } +} + +export class FilePreviewPlacementPolicy { + static readonly MIN_MASTER_WIDTH: number = 280; + static readonly MIN_CONVERSATION_WIDTH: number = 360; + static readonly MIN_PREVIEW_WIDTH: number = 360; + static readonly PANE_DIVIDER_WIDTH: number = 1; + + static resolve( + previewVisible: boolean, + largeScreenLayout: boolean, + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewPlacement { + return FilePreviewPlacementPolicy.resolveLayout( + previewVisible, + largeScreenLayout, + viewportWidth, + creases + ).placement; + } + + static resolveLayout( + previewVisible: boolean, + largeScreenLayout: boolean, + viewportWidth: number, + creases: ConversationLayoutCrease[], + preferredMasterWidth: number = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH + ): FilePreviewLayout { + if (!previewVisible) { + return new FilePreviewLayout(FilePreviewPlacement.Hidden); + } + if (!largeScreenLayout) { + return new FilePreviewLayout( + FilePreviewPlacement.CompactFullPage, + 0, + 0, + Math.max(0, viewportWidth), + 0, + Math.max(0, viewportWidth) + ); + } + const creaseLayout = FilePreviewPlacementPolicy.creaseAlignedTriplePane(viewportWidth, creases); + if (creaseLayout) { + return creaseLayout; + } + const flatLayout = FilePreviewPlacementPolicy.flatTriplePane( + viewportWidth, + creases, + preferredMasterWidth + ); + if (flatLayout) { + return flatLayout; + } + const focusGap = FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH; + const minimumFocusWidth = FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH + + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH + focusGap; + if (viewportWidth < minimumFocusWidth) { + return new FilePreviewLayout( + FilePreviewPlacement.CompactFullPage, + 0, + 0, + Math.max(0, viewportWidth), + 0, + Math.max(0, viewportWidth) + ); + } + const focusContentWidth = Math.max(0, viewportWidth - focusGap); + const conversationWidth = Math.floor(focusContentWidth / 2); + return new FilePreviewLayout( + FilePreviewPlacement.WideFocusSplit, + 0, + 0, + conversationWidth, + focusGap, + focusContentWidth - conversationWidth + ); + } + + private static flatTriplePane( + viewportWidth: number, + creases: ConversationLayoutCrease[], + preferredMasterWidth: number + ): FilePreviewLayout | undefined { + if (FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases).length > 0) { + return undefined; + } + const minimum = FilePreviewPlacementPolicy.MIN_MASTER_WIDTH + + FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH + + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH + + FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH * 2; + if (viewportWidth < minimum) { + return undefined; + } + const dividerWidth = FilePreviewPlacementPolicy.PANE_DIVIDER_WIDTH; + const maximumMasterWidth = viewportWidth - dividerWidth * 2 - + FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH - + FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH; + const masterWidth = Math.max( + FilePreviewPlacementPolicy.MIN_MASTER_WIDTH, + Math.min(preferredMasterWidth, maximumMasterWidth) + ); + const detailWidth = viewportWidth - masterWidth - dividerWidth * 2; + const conversationWidth = Math.floor(detailWidth / 2); + return new FilePreviewLayout( + FilePreviewPlacement.WideTriplePane, + masterWidth, + dividerWidth, + conversationWidth, + dividerWidth, + detailWidth - conversationWidth + ); + } + + private static creaseAlignedTriplePane( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): FilePreviewLayout | undefined { + const visible = FilePreviewPlacementPolicy.visibleCreases(viewportWidth, creases); + if (visible.length < 2) { + return undefined; + } + const first = visible[0]; + const second = visible[1]; + const masterWidth = first.left; + const conversationWidth = second.left - first.left - first.width; + const previewWidth = viewportWidth - second.left - second.width; + if (masterWidth < FilePreviewPlacementPolicy.MIN_MASTER_WIDTH || + conversationWidth < FilePreviewPlacementPolicy.MIN_CONVERSATION_WIDTH || + previewWidth < FilePreviewPlacementPolicy.MIN_PREVIEW_WIDTH) { + return undefined; + } + return new FilePreviewLayout( + FilePreviewPlacement.WideTriplePane, + masterWidth, + first.width, + conversationWidth, + second.width, + previewWidth + ); + } + + private static visibleCreases( + viewportWidth: number, + creases: ConversationLayoutCrease[] + ): ConversationLayoutCrease[] { + return creases + .filter((crease: ConversationLayoutCrease): boolean => { + return crease.left > 0 && crease.width >= 0 && crease.left + crease.width < viewportWidth; + }) + .sort((left: ConversationLayoutCrease, right: ConversationLayoutCrease): number => left.left - right.left); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets new file mode 100644 index 0000000000..88351095ff --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -0,0 +1,107 @@ +import { FilePreviewTarget } from './FilePreviewTarget'; + +export enum FilePreviewPhase { + Idle = 'idle', + Loading = 'loading', + Ready = 'ready', + Unsupported = 'unsupported', + Error = 'error' +} + +export enum FilePreviewRendererKind { + Text = 'text', + Markdown = 'markdown', + Image = 'image', + Unsupported = 'unsupported' +} + +@ObservedV2 +export class FilePreviewState { + @Trace visible: boolean = false; + @Trace phase: FilePreviewPhase = FilePreviewPhase.Idle; + @Trace target: FilePreviewTarget = FilePreviewTarget.empty(); + @Trace fileName: string = ''; + @Trace mimeType: string = ''; + @Trace fileSize: number = 0; + @Trace rendererKind: FilePreviewRendererKind = FilePreviewRendererKind.Unsupported; + @Trace textContent: string = ''; + @Trace contentBase64: string = ''; + @Trace truncated: boolean = false; + @Trace loadedBytes: number = 0; + @Trace errorText: string = ''; + @Trace errorRetryable: boolean = true; + @Trace requestVersion: number = 0; + scrollOffsetX: number = 0; + scrollOffsetY: number = 0; + hasRecordedScroll: boolean = false; + + begin(target: FilePreviewTarget): number { + this.requestVersion += 1; + this.visible = true; + this.phase = FilePreviewPhase.Loading; + this.target = target; + this.fileName = target.displayName; + this.mimeType = ''; + this.fileSize = 0; + this.rendererKind = FilePreviewRendererKind.Unsupported; + this.textContent = ''; + this.contentBase64 = ''; + this.truncated = false; + this.loadedBytes = 0; + this.errorText = ''; + this.errorRetryable = true; + this.resetScroll(); + return this.requestVersion; + } + + close(): void { + this.requestVersion += 1; + this.visible = false; + this.phase = FilePreviewPhase.Idle; + this.target = FilePreviewTarget.empty(); + this.fileName = ''; + this.mimeType = ''; + this.fileSize = 0; + this.rendererKind = FilePreviewRendererKind.Unsupported; + this.textContent = ''; + this.contentBase64 = ''; + this.truncated = false; + this.loadedBytes = 0; + this.errorText = ''; + this.errorRetryable = true; + this.resetScroll(); + } + + isCurrent(version: number, target: FilePreviewTarget): boolean { + return this.visible && this.requestVersion === version && + this.target.remotePath === target.remotePath && + this.target.sessionId === target.sessionId && + this.target.controlTargetEpoch === target.controlTargetEpoch; + } + + recordScroll(xOffset: number, yOffset: number): void { + this.scrollOffsetX = Math.max(0, xOffset); + this.scrollOffsetY = Math.max(0, yOffset); + this.hasRecordedScroll = true; + } + + initialScrollX(): number { + return this.hasRecordedScroll ? this.scrollOffsetX : 0; + } + + initialScrollY(lineHeight: number = 19, contextLines: number = 2): number { + if (this.hasRecordedScroll) { + return this.scrollOffsetY; + } + if (this.target.lineStart <= 1) { + return 0; + } + return Math.max(0, this.target.lineStart - 1 - contextLines) * lineHeight; + } + + private resetScroll(): void { + this.scrollOffsetX = 0; + this.scrollOffsetY = 0; + this.hasRecordedScroll = false; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets new file mode 100644 index 0000000000..7bc0ffb89a --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets @@ -0,0 +1,62 @@ +export class FilePreviewTarget { + readonly kind: string; + readonly rawReference: string; + readonly remotePath: string; + readonly displayName: string; + readonly sessionId: string; + readonly workspacePath: string; + readonly controlTargetEpoch: number; + readonly lineStart: number; + readonly lineEnd: number; + + constructor( + rawReference: string, + remotePath: string, + displayName: string, + sessionId: string, + workspacePath: string, + controlTargetEpoch: number, + lineStart: number = 0, + lineEnd: number = 0 + ) { + this.kind = 'remote_workspace_file'; + this.rawReference = rawReference; + this.remotePath = remotePath; + this.displayName = displayName; + this.sessionId = sessionId; + this.workspacePath = workspacePath; + this.controlTargetEpoch = controlTargetEpoch; + this.lineStart = lineStart; + this.lineEnd = lineEnd; + } + + static empty(): FilePreviewTarget { + return new FilePreviewTarget('', '', '', '', '', 0); + } + + isValid(): boolean { + return this.remotePath.length > 0 && this.sessionId.length > 0; + } +} + +export class FilePreviewTargetContext { + readonly sessionId: string; + readonly workspacePath: string; + readonly controlTargetEpoch: number; + + constructor(sessionId: string, workspacePath: string, controlTargetEpoch: number) { + this.sessionId = sessionId; + this.workspacePath = workspacePath; + this.controlTargetEpoch = controlTargetEpoch; + } +} + +export class FilePreviewRequest { + readonly reference: string; + readonly label: string; + + constructor(reference: string, label: string) { + this.reference = reference; + this.label = label; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets index f783456fcf..5c4db437fe 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets @@ -1,4 +1,10 @@ -import { ChatMessage, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; +import { + ChatMessage, + RemoteModelCatalog, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -18,6 +24,8 @@ export class GeneralChatPageState { @Trace apiUrl: string = ''; @Trace modelName: string = ''; @Trace hasApiKey: boolean = false; + @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); + @Trace selectedModelId: string = ''; @Trace statusText: string = ''; @Trace chatInput: string = ''; @Trace selectedImages: SelectedImageAttachment[] = []; @@ -89,6 +97,16 @@ export class GeneralChatPageState { this.serviceState = serviceState; } + setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { + this.modelCatalog = { + version: modelCatalog.version, + models: modelCatalog.models.slice(), + default_models: modelCatalog.default_models, + session_model_id: modelCatalog.session_model_id + }; + this.selectedModelId = selectedModelId; + } + setStatus(statusText: string): void { this.statusText = statusText; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index dd42dad127..a45b1f0b05 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -1,6 +1,18 @@ import { RecentWorkspaceEntry } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +export class RemoteCreateSessionContext { + readonly deviceId: string; + readonly workspacePath: string; + readonly agentType: string; + + constructor(deviceId: string, workspacePath: string, agentType: string = 'Claw') { + this.deviceId = deviceId; + this.workspacePath = workspacePath; + this.agentType = agentType; + } +} + @ObservedV2 export class RemoteCreateSessionState { @Trace draft: string = ''; @@ -38,11 +50,23 @@ export class RemoteCreateSessionState { setDevices(devices: CloudAccountDevice[]): void { this.devices = devices.slice(); + const selected = devices.find((device: CloudAccountDevice): boolean => + device.deviceId === this.selectedDeviceId + ); + if (selected) { + this.selectedDeviceName = selected.deviceName; + } this.isLoadingDevices = false; } setWorkspaces(workspaces: RecentWorkspaceEntry[]): void { this.workspaces = workspaces.slice(); + const selected = workspaces.find((workspace: RecentWorkspaceEntry): boolean => + workspace.path === this.selectedWorkspacePath + ); + if (selected) { + this.selectedWorkspaceName = selected.name; + } this.isLoadingWorkspaces = false; } @@ -61,6 +85,10 @@ export class RemoteCreateSessionState { this.errorText = ''; } + submissionContext(): RemoteCreateSessionContext { + return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + } + clearWorkspace(): void { this.selectedWorkspacePath = ''; this.selectedWorkspaceName = ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets index afba61c242..4c9ac5df2d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets @@ -109,7 +109,11 @@ export class RemoteSessionViewModel { await this.refreshSessions(); } - async createSession(agentType: string, instruction: string = ''): Promise { + async createSession( + agentType: string, + instruction: string = '', + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat + ): Promise { await this.sessions.create( agentType, this.hooks.isBusy(), @@ -119,7 +123,7 @@ export class RemoteSessionViewModel { this.pageState.setHasMoreMessages(false); this.hooks.onKnownStateReset(); this.hooks.onResetTimeline(session.sessionId); - this.hooks.onRouteChat(session.sessionId); + onRouteChat(session.sessionId); await this.hooks.onLoadModelCatalog(session.sessionId); await this.refreshSessions(); await this.hooks.onLoadActiveMessages(); @@ -133,19 +137,24 @@ export class RemoteSessionViewModel { path: string, currentPath: string, instruction: string = '', - agentType: string = 'code' + agentType: string = 'code', + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { if (path.length > 0 && path !== currentPath) { await this.hooks.onSelectWorkspace(path); - await this.createSession(agentType, instruction); + await this.createSession(agentType, instruction, onRouteChat); return; } if (path.length === 0 || path === currentPath) { - await this.createSession(agentType, instruction); + await this.createSession(agentType, instruction, onRouteChat); } } - async openSession(item: RemoteSession, currentWorkspacePath: string): Promise { + async openSession( + item: RemoteSession, + currentWorkspacePath: string, + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat + ): Promise { await this.sessions.open( item, item.workspacePath || currentWorkspacePath, @@ -158,7 +167,7 @@ export class RemoteSessionViewModel { this.pageState.setHasMoreMessages(false); this.files.clear(); this.pageState.clearComposer(); - this.hooks.onRouteChat(item.id); + onRouteChat(item.id); await this.hooks.onLoadModelCatalog(item.id); await this.hooks.onLoadActiveMessages(); if (this.pageState.activeSession.sessionId === session.sessionId) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets new file mode 100644 index 0000000000..ddfeb71439 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets @@ -0,0 +1,31 @@ +export enum SessionActionScope { + General = 'general', + Remote = 'remote' +} + +export class SessionActionCapabilities { + readonly canViewDetails: boolean; + readonly canArchive: boolean; + readonly canExport: boolean; + readonly canDelete: boolean; + + constructor(canViewDetails: boolean, canArchive: boolean, canExport: boolean, canDelete: boolean) { + this.canViewDetails = canViewDetails; + this.canArchive = canArchive; + this.canExport = canExport; + this.canDelete = canDelete; + } +} + +export class SessionActionPolicy { + static resolve(scope: SessionActionScope, agentType: string, busy: boolean): SessionActionCapabilities { + if (busy) { + return new SessionActionCapabilities(false, false, false, false); + } + if (scope === SessionActionScope.Remote) { + return new SessionActionCapabilities(true, false, false, true); + } + const isGeneralChat = agentType.toLowerCase() === 'chat'; + return new SessionActionCapabilities(true, isGeneralChat, isGeneralChat, true); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index cc5545af33..1ae3c6919b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -82,6 +82,17 @@ interface SyncSessionUpload { version: number; } +interface SyncSettingsEntry { + encrypted_data: string; + nonce: string; + version: number; +} + +export interface CloudSettingsBlob { + plaintext: string; + version: number; +} + /** Default BitFun cloud relay used by the desktop account flow. */ export const DEFAULT_CLOUD_RELAY_URL: string = 'https://remote.openbitfun.com/relay'; @@ -139,6 +150,25 @@ export class CloudAccountClient { return bundles; } + async fetchSettings(relayUrl: string, session: CloudAccountSession): Promise { + let entry: SyncSettingsEntry; + try { + entry = await this.request(relayUrl, '/api/sync/settings', 'GET', undefined, session.token); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 404) { + return undefined; + } + throw err instanceof Error ? err : new Error('Cloud settings request failed.'); + } + if (!entry || !entry.encrypted_data || !entry.nonce) { + return undefined; + } + return { + plaintext: await this.decryptSyncPayload(session.masterKey, entry.encrypted_data, entry.nonce), + version: entry.version + }; + } + async listDevices(relayUrl: string, session: CloudAccountSession): Promise { const devices = await this.request(relayUrl, '/api/devices', 'GET', undefined, session.token); return devices.map((device: CloudAccountDeviceWire): CloudAccountDevice => ({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets new file mode 100644 index 0000000000..e04aaa1055 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CodeSyntaxHighlighter.ets @@ -0,0 +1,422 @@ +export enum CodeSyntaxTokenKind { + Plain = 'plain', + LineNumber = 'line-number', + Keyword = 'keyword', + String = 'string', + Number = 'number', + Comment = 'comment', + Function = 'function', + Type = 'type', + Constant = 'constant', + Property = 'property' +} + +export class CodeSyntaxToken { + readonly id: string; + readonly text: string; + readonly kind: CodeSyntaxTokenKind; + readonly lineNumber: number; + + constructor(id: string, text: string, kind: CodeSyntaxTokenKind, lineNumber: number = 0) { + this.id = id; + this.text = text; + this.kind = kind; + this.lineNumber = lineNumber; + } +} + +export class CodeSyntaxHighlightCache { + private text: string = ''; + private fileName: string = ''; + private tokens: CodeSyntaxToken[] = []; + private initialized: boolean = false; + + tokensFor(text: string, fileName: string): CodeSyntaxToken[] { + if (this.initialized && this.text === text && this.fileName === fileName) { + return this.tokens; + } + this.text = text; + this.fileName = fileName; + this.tokens = CodeSyntaxHighlighter.tokenize(text, fileName); + this.initialized = true; + return this.tokens; + } +} + +const HIGHLIGHT_MAX_CHARACTERS: number = 256 * 1024; +const HIGHLIGHT_MAX_TOKENS: number = 12000; + +/** + * A bounded lexical colorizer for the native preview surface. It deliberately + * avoids building a full syntax tree and falls back to one plain span for + * large inputs so ArkUI does not have to mount thousands of child spans. + */ +export class CodeSyntaxHighlighter { + static tokenize(text: string, fileName: string): CodeSyntaxToken[] { + const extension = CodeSyntaxHighlighter.extension(fileName); + if (!CodeSyntaxHighlighter.supports(extension) || text.length > HIGHLIGHT_MAX_CHARACTERS) { + return CodeSyntaxHighlighter.plain(text); + } + + const tokens: CodeSyntaxToken[] = []; + const lineCount = CodeSyntaxHighlighter.lineCount(text); + const lineNumberWidth = String(lineCount).length; + let lineNumber = 1; + let index = 0; + let tokenIndex = 0; + let needsLineNumber = true; + + while (index < text.length) { + if (needsLineNumber) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + needsLineNumber = false; + } + + const character = text.charAt(index); + if (character === '\n') { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, '\n', CodeSyntaxTokenKind.Plain, lineNumber + )); + index += 1; + lineNumber += 1; + needsLineNumber = true; + continue; + } + + const lineComment = CodeSyntaxHighlighter.lineCommentAt(text, index, extension); + if (lineComment.length > 0) { + const end = CodeSyntaxHighlighter.lineEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Comment, + lineNumber + )); + index = end; + continue; + } + + const blockCommentEnd = CodeSyntaxHighlighter.blockCommentEnd(text, index, extension); + if (blockCommentEnd > index) { + const block = text.slice(index, blockCommentEnd); + CodeSyntaxHighlighter.pushMultilineToken( + tokens, + block, + CodeSyntaxTokenKind.Comment, + lineNumberWidth, + lineNumber, + tokenIndex + ); + const consumedLines = CodeSyntaxHighlighter.newlineCount(block); + tokenIndex = tokens.length; + lineNumber += consumedLines; + needsLineNumber = block.endsWith('\n'); + index = blockCommentEnd; + continue; + } + + if (character === '\'' || character === '"' || character === '`') { + const end = CodeSyntaxHighlighter.stringEnd(text, index, character); + const value = text.slice(index, end); + CodeSyntaxHighlighter.pushMultilineToken( + tokens, + value, + CodeSyntaxTokenKind.String, + lineNumberWidth, + lineNumber, + tokenIndex + ); + const consumedLines = CodeSyntaxHighlighter.newlineCount(value); + tokenIndex = tokens.length; + lineNumber += consumedLines; + needsLineNumber = value.endsWith('\n'); + index = end; + continue; + } + + if (CodeSyntaxHighlighter.isDigit(character)) { + const end = CodeSyntaxHighlighter.numberEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Number, + lineNumber + )); + index = end; + continue; + } + + if (CodeSyntaxHighlighter.isIdentifierStart(character)) { + const end = CodeSyntaxHighlighter.identifierEnd(text, index); + const word = text.slice(index, end); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + word, + CodeSyntaxHighlighter.identifierKind(text, end, word, extension), + lineNumber + )); + index = end; + continue; + } + + const end = CodeSyntaxHighlighter.plainEnd(text, index); + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + text.slice(index, end), + CodeSyntaxTokenKind.Plain, + lineNumber + )); + index = end; + + if (tokens.length > HIGHLIGHT_MAX_TOKENS) { + return CodeSyntaxHighlighter.plain(text); + } + } + + if (text.length === 0 || needsLineNumber) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + } + return tokens; + } + + private static pushMultilineToken( + tokens: CodeSyntaxToken[], + value: string, + kind: CodeSyntaxTokenKind, + lineNumberWidth: number, + firstLineNumber: number, + firstTokenIndex: number + ): void { + const parts = value.split('\n'); + let lineNumber = firstLineNumber; + let tokenIndex = firstTokenIndex; + parts.forEach((part: string, index: number) => { + if (part.length > 0) { + tokens.push(new CodeSyntaxToken(`syntax-${tokenIndex++}`, part, kind, lineNumber)); + } + if (index < parts.length - 1) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, '\n', CodeSyntaxTokenKind.Plain, lineNumber + )); + lineNumber += 1; + if (index < parts.length - 2) { + tokens.push(new CodeSyntaxToken( + `syntax-${tokenIndex++}`, + CodeSyntaxHighlighter.linePrefix(lineNumber, lineNumberWidth), + CodeSyntaxTokenKind.LineNumber, + lineNumber + )); + } + } + }); + } + + private static plain(text: string): CodeSyntaxToken[] { + return [new CodeSyntaxToken('syntax-plain', CodeSyntaxHighlighter.numberedText(text), CodeSyntaxTokenKind.Plain)]; + } + + private static numberedText(text: string): string { + const lines = text.split('\n'); + const width = String(lines.length).length; + return lines.map((line: string, index: number): string => { + return `${CodeSyntaxHighlighter.linePrefix(index + 1, width)}${line}`; + }).join('\n'); + } + + private static linePrefix(lineNumber: number, width: number): string { + let value = String(lineNumber); + while (value.length < width) { + value = ` ${value}`; + } + return `${value} `; + } + + private static identifierKind( + text: string, + end: number, + word: string, + extension: string + ): CodeSyntaxTokenKind { + if (CodeSyntaxHighlighter.isKeyword(word, extension)) { + return CodeSyntaxTokenKind.Keyword; + } + if ('|true|false|null|undefined|none|nil|self|this|super|'.indexOf(`|${word.toLowerCase()}|`) >= 0) { + return CodeSyntaxTokenKind.Constant; + } + const next = CodeSyntaxHighlighter.nextNonWhitespace(text, end); + if (next === '(' || next === '!') { + return CodeSyntaxTokenKind.Function; + } + if (next === ':') { + return CodeSyntaxTokenKind.Property; + } + const first = word.charAt(0); + if (first >= 'A' && first <= 'Z') { + return CodeSyntaxTokenKind.Type; + } + return CodeSyntaxTokenKind.Plain; + } + + private static isKeyword(word: string, extension: string): boolean { + const normalized = word.toLowerCase(); + let keywords = '|if|else|for|while|do|switch|case|break|continue|return|throw|try|catch|finally|new|in|of|'; + if ('|js|jsx|ts|tsx|mjs|cjs|ets|vue|svelte|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'const|let|var|function|class|extends|implements|interface|type|enum|import|export|from|as|async|await|yield|default|delete|instanceof|typeof|void|public|private|protected|readonly|static|get|set|declare|namespace|'; + } else if ('|rs|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'fn|let|mut|struct|enum|impl|trait|use|mod|pub|crate|where|match|move|ref|async|await|dyn|unsafe|extern|const|static|type|loop|'; + } else if ('|py|pyw|pyi|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'def|class|import|from|as|lambda|with|async|await|yield|raise|pass|global|nonlocal|assert|del|elif|except|finally|is|not|and|or|'; + } else if ('|go|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'func|package|import|defer|go|select|chan|map|range|struct|interface|type|var|const|fallthrough|'; + } else if ('|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|swift|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'class|struct|interface|enum|namespace|using|import|package|public|private|protected|static|final|virtual|override|abstract|const|var|val|fun|func|operator|template|typename|extends|implements|throws|'; + } else if ('|sh|bash|zsh|fish|ps1|bat|cmd|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'then|fi|elif|done|function|select|until|export|local|readonly|declare|set|unset|'; + } else if ('|sql|'.indexOf(`|${extension}|`) >= 0) { + keywords += 'select|insert|update|delete|create|alter|drop|from|join|inner|left|right|on|where|group|order|by|having|limit|offset|union|all|distinct|into|values|table|index|view|and|or|not|null|'; + } + return keywords.indexOf(`|${normalized}|`) >= 0; + } + + private static lineCommentAt(text: string, index: number, extension: string): string { + if ('|py|pyw|pyi|rb|sh|bash|zsh|fish|yaml|yml|toml|conf|cfg|ini|'.indexOf(`|${extension}|`) >= 0 && + text.charAt(index) === '#') { + return '#'; + } + if (extension === 'sql' && text.slice(index, index + 2) === '--') { + return '--'; + } + if (text.slice(index, index + 2) === '//') { + return '//'; + } + return ''; + } + + private static blockCommentEnd(text: string, index: number, extension: string): number { + if (text.slice(index, index + 4) === '', index + 4); + return htmlEnd >= 0 ? htmlEnd + 3 : text.length; + } + if (extension !== 'py' && text.slice(index, index + 2) === '/*') { + const end = text.indexOf('*/', index + 2); + return end >= 0 ? end + 2 : text.length; + } + return index; + } + + private static stringEnd(text: string, start: number, quote: string): number { + let index = start + 1; + let escaped = false; + while (index < text.length) { + const character = text.charAt(index); + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + return index + 1; + } else if (character === '\n' && quote !== '`') { + return index; + } + index += 1; + } + return text.length; + } + + private static numberEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length) { + const character = text.charAt(index); + if (!CodeSyntaxHighlighter.isDigit(character) && character !== '.' && character !== '_' && + character.toLowerCase() !== 'x' && character.toLowerCase() !== 'b' && + !(character.toLowerCase() >= 'a' && character.toLowerCase() <= 'f')) { + break; + } + index += 1; + } + return index; + } + + private static identifierEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length && CodeSyntaxHighlighter.isIdentifierPart(text.charAt(index))) { + index += 1; + } + return index; + } + + private static plainEnd(text: string, start: number): number { + let index = start + 1; + while (index < text.length) { + const character = text.charAt(index); + if (character === '\n' || character === '\'' || character === '"' || character === '`' || + CodeSyntaxHighlighter.isDigit(character) || CodeSyntaxHighlighter.isIdentifierStart(character) || + text.slice(index, index + 2) === '//' || text.slice(index, index + 2) === '/*' || + text.slice(index, index + 2) === '--' || character === '#') { + break; + } + index += 1; + } + return index; + } + + private static lineEnd(text: string, start: number): number { + const end = text.indexOf('\n', start); + return end >= 0 ? end : text.length; + } + + private static nextNonWhitespace(text: string, start: number): string { + let index = start; + while (index < text.length && (text.charAt(index) === ' ' || text.charAt(index) === '\t')) { + index += 1; + } + return index < text.length ? text.charAt(index) : ''; + } + + private static lineCount(text: string): number { + return CodeSyntaxHighlighter.newlineCount(text) + 1; + } + + private static newlineCount(text: string): number { + let count = 0; + for (let index = 0; index < text.length; index++) { + if (text.charAt(index) === '\n') { + count += 1; + } + } + return count; + } + + private static isDigit(character: string): boolean { + return character >= '0' && character <= '9'; + } + + private static isIdentifierStart(character: string): boolean { + return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + character === '_' || character === '$'; + } + + private static isIdentifierPart(character: string): boolean { + return CodeSyntaxHighlighter.isIdentifierStart(character) || CodeSyntaxHighlighter.isDigit(character); + } + + private static extension(fileName: string): string { + const normalized = fileName.replace(/\\/g, '/').split('/').pop() || ''; + const dot = normalized.lastIndexOf('.'); + return dot >= 0 ? normalized.slice(dot + 1).toLowerCase() : ''; + } + + private static supports(extension: string): boolean { + return '|js|jsx|ts|tsx|mjs|cjs|ets|vue|svelte|rs|py|pyw|pyi|rb|go|java|kt|kts|scala|groovy|c|cpp|cc|cxx|h|hpp|hxx|hh|cs|swift|php|css|scss|less|json|jsonc|yaml|yml|toml|xml|html|htm|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|'.indexOf(`|${extension}|`) >= 0; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets new file mode 100644 index 0000000000..ad377af76d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewErrorPolicy.ets @@ -0,0 +1,42 @@ +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; + +export class FilePreviewErrorResult { + readonly text: string; + readonly retryable: boolean; + + constructor(text: string, retryable: boolean) { + this.text = text; + this.retryable = retryable; + } +} + +export class FilePreviewErrorPolicy { + static resolve(err: Object): FilePreviewErrorResult { + const raw = err instanceof Error ? err.message : JSON.stringify(err); + const text = raw.toLowerCase(); + if (text.indexOf('file not found') >= 0 || text.indexOf('file does not exist') >= 0 || + text.indexOf('not a regular file') >= 0 || text.indexOf('no such file') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.notFound'), true); + } + if (text.indexOf('could not be resolved') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.unavailable'), true); + } + if (text.indexOf('access denied') >= 0 || text.indexOf('restricted') >= 0 || + text.indexOf('outside') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.accessDenied'), false); + } + if (text.indexOf('file too large') >= 0 || text.indexOf('too large') >= 0) { + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.tooLarge'), false); + } + const connectionText = ConnectionErrorPolicy.errorText(err); + if (connectionText !== raw || /[\u3400-\u9fff]/.test(raw)) { + return new FilePreviewErrorResult(connectionText, true); + } + return new FilePreviewErrorResult(RemoteI18n.t('filePreview.loadFailed'), true); + } + + static errorText(err: Object): string { + return FilePreviewErrorPolicy.resolve(err).text; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets new file mode 100644 index 0000000000..540d347c70 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FilePreviewPolicy.ets @@ -0,0 +1,13 @@ +export class FilePreviewPolicy { + static readonly TEXT_MAX_BYTES: number = 2 * 1024 * 1024; + static readonly IMAGE_MAX_BYTES: number = 12 * 1024 * 1024; + + static textReadLimit(fileSize: number): number { + return fileSize > 0 ? Math.min(fileSize, FilePreviewPolicy.TEXT_MAX_BYTES) : + FilePreviewPolicy.TEXT_MAX_BYTES; + } + + static canPreviewImage(fileSize: number): boolean { + return fileSize <= FilePreviewPolicy.IMAGE_MAX_BYTES; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets new file mode 100644 index 0000000000..a83708868e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -0,0 +1,155 @@ +import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { RemoteUiState } from './RemoteUiState'; + +export enum FileReferenceKind { + RemoteWorkspaceFile = 'remote_workspace_file', + HttpUrl = 'http_url', + Anchor = 'anchor', + UnsupportedScheme = 'unsupported_scheme', + Invalid = 'invalid' +} + +export class FileReferenceResolution { + readonly kind: FileReferenceKind; + readonly target?: FilePreviewTarget; + + constructor(kind: FileReferenceKind, target?: FilePreviewTarget) { + this.kind = kind; + this.target = target; + } +} + +export class FileTargetResolver { + static matchesRemotePath(reference: string, remotePath: string): boolean { + const raw = FileTargetResolver.cleanReference(reference); + if (raw.length === 0 || remotePath.length === 0 || raw.indexOf('#') === 0) { + return false; + } + const range = FileTargetResolver.extractLineRange(raw); + if (FileTargetResolver.hasUnsupportedScheme(range.path)) { + return false; + } + return RemoteUiState.normalizeRemoteFilePath(range.path) === remotePath; + } + + static resolve(reference: string, label: string, context: FilePreviewTargetContext): FileReferenceResolution { + const raw = FileTargetResolver.cleanReference(reference); + if (raw.length === 0) { + return new FileReferenceResolution(FileReferenceKind.Invalid); + } + const lower = raw.toLowerCase(); + if (lower.indexOf('http://') === 0 || lower.indexOf('https://') === 0) { + return new FileReferenceResolution(FileReferenceKind.HttpUrl); + } + if (raw.indexOf('#') === 0) { + return new FileReferenceResolution(FileReferenceKind.Anchor); + } + const range = FileTargetResolver.extractLineRange(raw); + if (FileTargetResolver.hasUnsupportedScheme(range.path)) { + return new FileReferenceResolution(FileReferenceKind.UnsupportedScheme); + } + + const remotePath = RemoteUiState.normalizeRemoteFilePath(range.path); + if (remotePath.length === 0 || remotePath === '/') { + return new FileReferenceResolution(FileReferenceKind.Invalid); + } + const displayName = label.trim().length > 0 ? label.trim() : FileTargetResolver.basename(remotePath); + return new FileReferenceResolution( + FileReferenceKind.RemoteWorkspaceFile, + new FilePreviewTarget( + raw, + remotePath, + displayName, + context.sessionId, + context.workspacePath, + context.controlTargetEpoch, + range.start, + range.end + ) + ); + } + + private static extractLineRange(reference: string): FileTargetLineRange { + const hashIndex = reference.lastIndexOf('#'); + if (hashIndex > 0) { + const parsed = FileTargetResolver.parseLineMarker(reference.slice(hashIndex + 1)); + if (parsed.start > 0) { + return new FileTargetLineRange(reference.slice(0, hashIndex), parsed.start, parsed.end); + } + } + const colon = reference.match(/^(.+):(\d+)(?:-(\d+))?$/); + if (colon && !FileTargetResolver.isWindowsDrivePrefix(colon[1])) { + return new FileTargetLineRange( + colon[1], + Number.parseInt(colon[2]), + colon[3] ? Number.parseInt(colon[3]) : 0 + ); + } + return new FileTargetLineRange(reference, 0, 0); + } + + private static parseLineMarker(marker: string): FileTargetLineRange { + const match = marker.match(/^L?(\d+)(?:-L?(\d+))?$/i); + if (!match) { + return new FileTargetLineRange('', 0, 0); + } + return new FileTargetLineRange( + '', + Number.parseInt(match[1]), + match[2] ? Number.parseInt(match[2]) : 0 + ); + } + + private static hasUnsupportedScheme(reference: string): boolean { + const scheme = reference.match(/^([A-Za-z][A-Za-z0-9+.-]*):/); + if (!scheme) { + return false; + } + if (scheme[1].length === 1 && reference.length >= 3 && + (reference.charAt(2) === '/' || reference.charAt(2) === '\\')) { + return false; + } + const lower = scheme[1].toLowerCase(); + return lower !== 'computer' && lower !== 'file'; + } + + private static isWindowsDrivePrefix(value: string): boolean { + return value.length === 1 && /[A-Za-z]/.test(value); + } + + private static cleanReference(reference: string): string { + let clean = reference.trim(); + while (clean.length > 0) { + const last = clean.charAt(clean.length - 1); + if (last === ',' || last === '.' || last === ';' || last === ':' || last === ')' || + last === ']' || last === '}' || last === '>' || last === ',' || last === '。' || + last === ';' || last === ':') { + clean = clean.slice(0, clean.length - 1); + } else { + break; + } + } + try { + return decodeURIComponent(clean); + } catch (_err) { + return clean; + } + } + + private static basename(path: string): string { + const parts = path.replace(/\\/g, '/').split('/'); + return parts[parts.length - 1] || path || 'file'; + } +} + +class FileTargetLineRange { + readonly path: string; + readonly start: number; + readonly end: number; + + constructor(path: string, start: number, end: number) { + this.path = path; + this.start = start; + this.end = end; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets index fd910fac71..bfb4ac726e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownParser.ets @@ -22,6 +22,22 @@ export interface ParsedMarkdownBlock { inlines: ParsedMarkdownInline[]; } +export class MarkdownParseCache { + private text: string = ''; + private blocks: ParsedMarkdownBlock[] = []; + private initialized: boolean = false; + + blocksFor(text: string): ParsedMarkdownBlock[] { + if (this.initialized && this.text === text) { + return this.blocks; + } + this.text = text; + this.blocks = MarkdownParser.parse(text); + this.initialized = true; + return this.blocks; + } +} + export class MarkdownParser { static parse(text: string): ParsedMarkdownBlock[] { const lines = text.replace(/\r\n/g, '\n').split('\n'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets new file mode 100644 index 0000000000..a891801a75 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -0,0 +1,103 @@ +import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; +import { + MarkdownParser, + ParsedMarkdownBlock, + ParsedMarkdownInline, + ParsedMarkdownListItem +} from './MarkdownParser'; + +export class MessageFileReference { + readonly id: string; + readonly path: string; + readonly remotePath: string; + readonly label: string; + + constructor(id: string, path: string, remotePath: string, label: string) { + this.id = id; + this.path = path; + this.remotePath = remotePath; + this.label = label; + } +} + +export class MessageFileReferenceProjectionCache { + private source: string = ''; + private references: MessageFileReference[] = []; + private initialized: boolean = false; + + referencesFor(source: string): MessageFileReference[] { + if (this.initialized && this.source === source) { + return this.references; + } + this.source = source; + this.references = MessageFileReferenceProjector.project(source); + this.initialized = true; + return this.references; + } +} + +export class MessageFileReferenceProjector { + private static readonly CONTEXT: FilePreviewTargetContext = + new FilePreviewTargetContext('_message_projection_', '', 0); + + static project(source: string, limit: number = 4): MessageFileReference[] { + const references: MessageFileReference[] = []; + const seenRemotePaths = new Set(); + MarkdownParser.parse(source).forEach((block: ParsedMarkdownBlock) => { + MessageFileReferenceProjector.collectInlines(block.inlines, references, seenRemotePaths, limit); + block.items.forEach((item: ParsedMarkdownListItem) => { + MessageFileReferenceProjector.collectInlines(item.inlines, references, seenRemotePaths, limit); + }); + }); + return references; + } + + private static collectInlines( + inlines: ParsedMarkdownInline[], + references: MessageFileReference[], + seenRemotePaths: Set, + limit: number + ): void { + inlines.forEach((inline: ParsedMarkdownInline) => { + if (references.length >= limit) { + return; + } + if (inline.type === 'link') { + MessageFileReferenceProjector.add(inline.url, references, seenRemotePaths); + return; + } + if (inline.type === 'code') { + return; + } + const found = inline.text.match(/computer:\/\/[^\s)\]}>"']+/g) || []; + found.forEach((reference: string) => { + if (references.length < limit) { + MessageFileReferenceProjector.add(reference, references, seenRemotePaths); + } + }); + }); + } + + private static add( + reference: string, + references: MessageFileReference[], + seenRemotePaths: Set + ): void { + if (reference.trim().toLowerCase().indexOf('computer://') !== 0) { + return; + } + const resolution = FileTargetResolver.resolve(reference, '', MessageFileReferenceProjector.CONTEXT); + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target || + seenRemotePaths.has(resolution.target.remotePath)) { + return; + } + seenRemotePaths.add(resolution.target.remotePath); + references.push(new MessageFileReference( + `file-${references.length}-${resolution.target.remotePath}`, + resolution.target.rawReference, + resolution.target.remotePath, + resolution.target.displayName + )); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets index d6341d01ec..05e8a8858b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFileDownloadController.ets @@ -4,12 +4,11 @@ import { FileInfo, ReadFileResult } from '../model/RemoteModels'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from './ConnectionErrorPolicy'; import { Encoding } from './Encoding'; +import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; import { RemoteUiState } from './RemoteUiState'; +import { RemoteLogger } from './RemoteLogger'; -export interface RemoteFileDownloadClient { - getFileInfo(path: string, sessionId?: string): Promise; - readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void): Promise; -} +export interface RemoteFileDownloadClient extends RemoteWorkspaceFileClient {} export interface RemoteFileDownloadScheduler { setTimeout(callback: () => void, delayMs: number): number; @@ -91,9 +90,11 @@ export class RemoteFileDownloadController { } this.cancelClearTimer(); try { + RemoteLogger.info(`file_download start session=${sessionId.length > 0 ? 'set' : 'none'}`); this.onBusy(true); this.setDownloadStatus(path, '', RemoteI18n.t('status.fileInfo')); const info = await this.client.getFileInfo(remotePath, sessionId); + RemoteLogger.info(`file_download metadata name=${info.name} size=${info.size} mime=${info.mimeType}`); this.setDownloadStatus(path, '', RemoteI18n.f('status.prepareDownload', RemoteUiState.formatBytes(info.size))); const result: ReadFileResult = await this.client.readFile(remotePath, sessionId, (downloaded: number, total: number) => { this.setDownloadStatus( @@ -102,7 +103,9 @@ export class RemoteFileDownloadController { `${RemoteUiState.formatBytes(downloaded)} / ${RemoteUiState.formatBytes(total)}` ); }); + RemoteLogger.info(`file_download data_ready name=${result.name} size=${result.size}`); await this.fileSaver.save(result); + RemoteLogger.info(`file_download saved name=${result.name} size=${result.size}`); this.setDownloadStatus( path, path, @@ -110,7 +113,9 @@ export class RemoteFileDownloadController { ); this.onStatusText(this.fileDownloadStatus); } catch (err) { - this.setDownloadStatus(this.downloadingFilePath, this.downloadedFilePath, ConnectionErrorPolicy.errorText(err)); + const errorText = ConnectionErrorPolicy.errorText(err); + RemoteLogger.error(`file_download failed message=${errorText}`); + this.setDownloadStatus(this.downloadingFilePath, this.downloadedFilePath, errorText); this.onStatusText(this.fileDownloadStatus); } finally { this.onBusy(false); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets new file mode 100644 index 0000000000..a3299361f5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets @@ -0,0 +1,266 @@ +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; +import { + FilePreviewPhase, + FilePreviewRendererKind, + FilePreviewState +} from '../pages/state/FilePreviewState'; +import { FilePreviewTarget } from '../pages/state/FilePreviewTarget'; +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { Encoding } from './Encoding'; +import { FilePreviewErrorPolicy } from './FilePreviewErrorPolicy'; +import { FilePreviewPolicy } from './FilePreviewPolicy'; +import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; + +export class RemoteFilePreviewController { + private readonly client: RemoteWorkspaceFileClient; + private readonly state: FilePreviewState; + private readonly remoteAvailable: () => boolean; + private readonly currentControlTargetEpoch: () => number; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + remoteAvailable: () => boolean, + currentControlTargetEpoch: () => number + ) { + this.client = client; + this.state = state; + this.remoteAvailable = remoteAvailable; + this.currentControlTargetEpoch = currentControlTargetEpoch; + } + + async open(target: FilePreviewTarget): Promise { + if (!target.isValid() || !this.remoteAvailable() || + target.controlTargetEpoch !== this.currentControlTargetEpoch()) { + return; + } + const version = this.state.begin(target); + try { + const info = await this.client.getFileInfo(target.remotePath, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + this.applyMetadata(info); + const renderer = RemoteFilePreviewController.rendererFor(info.name, info.mimeType); + this.state.rendererKind = renderer; + if (renderer === FilePreviewRendererKind.Unsupported || + (renderer === FilePreviewRendererKind.Image && !FilePreviewPolicy.canPreviewImage(info.size))) { + this.state.phase = FilePreviewPhase.Unsupported; + return; + } + if (renderer === FilePreviewRendererKind.Image) { + const image = await this.client.readFile(target.remotePath, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + if (!FilePreviewPolicy.canPreviewImage(image.size)) { + this.state.fileName = image.name; + this.state.fileSize = image.size; + this.state.mimeType = image.mimeType; + this.state.phase = FilePreviewPhase.Unsupported; + return; + } + this.applyImage(image); + return; + } + const limit = FilePreviewPolicy.textReadLimit(info.size); + const chunk = await this.client.readFileChunk(target.remotePath, 0, limit, target.sessionId); + if (!this.isCurrentTarget(version, target) || this.failIfUnavailable()) { + return; + } + this.applyText(chunk, info.size > limit); + } catch (err) { + if (!this.isCurrentTarget(version, target)) { + return; + } + this.state.phase = FilePreviewPhase.Error; + if (this.remoteAvailable()) { + const failure = FilePreviewErrorPolicy.resolve(err); + this.state.errorText = failure.text; + this.state.errorRetryable = failure.retryable; + } else { + this.state.errorText = RemoteI18n.t('filePreview.offline'); + this.state.errorRetryable = true; + } + } + } + + async refresh(): Promise { + if (!this.state.visible || !this.state.target.isValid()) { + return; + } + await this.open(this.state.target); + } + + close(): void { + this.state.close(); + } + + private isCurrentTarget(version: number, target: FilePreviewTarget): boolean { + return this.state.isCurrent(version, target) && + target.controlTargetEpoch === this.currentControlTargetEpoch(); + } + + private failIfUnavailable(): boolean { + if (this.remoteAvailable()) { + return false; + } + this.state.phase = FilePreviewPhase.Error; + this.state.errorText = RemoteI18n.t('filePreview.offline'); + this.state.errorRetryable = true; + return true; + } + + private applyMetadata(info: FileInfo): void { + this.state.fileName = info.name; + this.state.fileSize = info.size; + this.state.mimeType = info.mimeType; + } + + private applyText(chunk: ReadFileChunkResult, truncated: boolean): void { + const bytes = Encoding.base64ToBytes(chunk.contentBase64); + if (RemoteFilePreviewController.isBinary(bytes)) { + this.state.phase = FilePreviewPhase.Unsupported; + this.state.rendererKind = FilePreviewRendererKind.Unsupported; + this.state.loadedBytes = bytes.length; + return; + } + const text = Encoding.bytesToUtf8(bytes); + if (RemoteFilePreviewController.isSuspiciousText(bytes, text)) { + this.state.phase = FilePreviewPhase.Unsupported; + this.state.rendererKind = FilePreviewRendererKind.Unsupported; + this.state.loadedBytes = bytes.length; + return; + } + this.state.textContent = text; + this.state.contentBase64 = ''; + this.state.loadedBytes = bytes.length; + this.state.truncated = truncated || chunk.totalSize > bytes.length; + this.state.phase = FilePreviewPhase.Ready; + } + + private applyImage(result: ReadFileResult): void { + this.state.fileName = result.name; + this.state.fileSize = result.size; + this.state.mimeType = result.mimeType; + this.state.contentBase64 = result.contentBase64; + this.state.textContent = ''; + this.state.loadedBytes = result.size; + this.state.truncated = false; + this.state.phase = FilePreviewPhase.Ready; + } + + static rendererFor(name: string, mimeType: string): FilePreviewRendererKind { + const mime = mimeType.toLowerCase(); + const extension = RemoteFilePreviewController.extension(name); + if (mime.indexOf('image/') === 0 && extension !== 'svg') { + return FilePreviewRendererKind.Image; + } + if (mime === 'text/markdown' || extension === 'md' || extension === 'mdx') { + return FilePreviewRendererKind.Markdown; + } + if (mime.indexOf('text/') === 0 || mime === 'application/json' || mime === 'application/xml' || + RemoteFilePreviewController.isTextExtension(extension) || + RemoteFilePreviewController.isTextFileName(name)) { + return FilePreviewRendererKind.Text; + } + return FilePreviewRendererKind.Unsupported; + } + + private static extension(name: string): string { + const fileName = name.replace(/\\/g, '/').split('/').pop() || ''; + const dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ''; + } + + private static isTextExtension(extension: string): boolean { + return '|js|jsx|ts|tsx|mjs|cjs|py|rs|go|java|kt|c|cpp|h|hpp|cs|rb|php|swift|vue|svelte|css|scss|less|json|jsonc|yaml|yml|toml|xml|csv|tsv|txt|log|sh|bash|zsh|fish|ps1|bat|cmd|sql|graphql|gql|proto|lock|env|ini|cfg|conf|ets|gitignore|editorconfig|'.indexOf(`|${extension}|`) >= 0; + } + + private static isTextFileName(name: string): boolean { + const fileName = name.replace(/\\/g, '/').split('/').pop()?.toLowerCase() || ''; + return fileName === 'dockerfile' || fileName === 'makefile' || fileName === 'justfile' || + fileName === 'gemfile' || fileName === 'rakefile' || fileName === 'procfile' || + fileName === 'license' || fileName === 'readme' || fileName === 'changelog'; + } + + private static isBinary(bytes: Uint8Array): boolean { + const limit = Math.min(bytes.length, 4096); + for (let index = 0; index < limit; index++) { + if (bytes[index] === 0) { + return true; + } + } + return false; + } + + private static isSuspiciousText(bytes: Uint8Array, text: string): boolean { + if (!RemoteFilePreviewController.isValidUtf8(bytes)) { + return true; + } + if (text.indexOf('\uFFFD') >= 0) { + return true; + } + const limit = Math.min(bytes.length, 4096); + let controls = 0; + for (let index = 0; index < limit; index++) { + const value = bytes[index]; + if (value < 32 && value !== 9 && value !== 10 && value !== 13) { + controls += 1; + } + } + return limit > 0 && controls / limit > 0.02; + } + + private static isValidUtf8(bytes: Uint8Array): boolean { + let index = 0; + while (index < bytes.length) { + const first = bytes[index]; + if (first <= 0x7F) { + index += 1; + continue; + } + let continuationCount = 0; + let minimumFirst = 0x80; + let maximumFirst = 0xBF; + if (first >= 0xC2 && first <= 0xDF) { + continuationCount = 1; + } else if (first === 0xE0) { + continuationCount = 2; + minimumFirst = 0xA0; + } else if (first >= 0xE1 && first <= 0xEC) { + continuationCount = 2; + } else if (first === 0xED) { + continuationCount = 2; + maximumFirst = 0x9F; + } else if (first >= 0xEE && first <= 0xEF) { + continuationCount = 2; + } else if (first === 0xF0) { + continuationCount = 3; + minimumFirst = 0x90; + } else if (first >= 0xF1 && first <= 0xF3) { + continuationCount = 3; + } else if (first === 0xF4) { + continuationCount = 3; + maximumFirst = 0x8F; + } else { + return false; + } + if (index + continuationCount >= bytes.length) { + return false; + } + const second = bytes[index + 1]; + if (second < minimumFirst || second > maximumFirst) { + return false; + } + for (let offset = 2; offset <= continuationCount; offset++) { + const continuation = bytes[index + offset]; + if (continuation < 0x80 || continuation > 0xBF) { + return false; + } + } + index += continuationCount + 1; + } + return true; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index aa12b94d6b..774addea4c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,4 +1,4 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; @@ -317,6 +317,25 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } + async readFileChunk( + path: string, + offset: number, + limit: number, + sessionId?: string + ): Promise { + const response = await this.send( + RemoteCommandFactory.readFileChunk(path, offset, limit, sessionId) + ); + return { + name: response.name || RemoteResponseMapper.basename(path), + contentBase64: response.chunk_base64 || '', + offset: response.offset || offset, + chunkSize: response.chunk_size || 0, + totalSize: response.total_size || 0, + mimeType: response.mime_type || 'application/octet-stream' + }; + } + async readFile(path: string, sessionId?: string, onProgress?: (downloaded: number, total: number) => void): Promise { const chunkSize = 3 * 1024 * 1024; let offset = 0; @@ -325,14 +344,12 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile let totalSize = 0; const chunks: string[] = []; while (true) { - const command = RemoteCommandFactory.readFileChunk(path, offset, chunkSize, sessionId); - const response = await this.send(command); - const chunk = response.chunk_base64 || ''; - const readSize = response.chunk_size || 0; - chunks.push(chunk); + const response = await this.readFileChunk(path, offset, chunkSize, sessionId); + const readSize = response.chunkSize; + chunks.push(response.contentBase64); fileName = response.name || fileName; - mimeType = response.mime_type || mimeType; - totalSize = response.total_size || totalSize; + mimeType = response.mimeType || mimeType; + totalSize = response.totalSize || totalSize; offset += readSize; if (onProgress) { onProgress(offset, totalSize); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets new file mode 100644 index 0000000000..6a64a622a5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceFileClient.ets @@ -0,0 +1,12 @@ +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; + +/** Stable mobile-side port for files owned by the active remote workspace. */ +export interface RemoteWorkspaceFileClient { + getFileInfo(path: string, sessionId?: string): Promise; + readFileChunk(path: string, offset: number, limit: number, sessionId?: string): Promise; + readFile( + path: string, + sessionId?: string, + onProgress?: (downloaded: number, total: number) => void + ): Promise; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets new file mode 100644 index 0000000000..2de57cceba --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ToolFileReferenceResolver.ets @@ -0,0 +1,66 @@ +export interface ToolFileInputPayload { + path?: string; + file_path?: string; + filePath?: string; +} + +export class ToolFileReference { + readonly path: string; + readonly label: string; + + constructor(path: string, label: string) { + this.path = path; + this.label = label; + } +} + +export class ToolFileReferenceResolver { + static resolve( + toolName: string, + toolInput?: Object, + inputPreview: string = '' + ): ToolFileReference | undefined { + if (!ToolFileReferenceResolver.supportsTool(toolName)) { + return undefined; + } + const payload = ToolFileReferenceResolver.payload(toolInput, inputPreview); + const path = (payload.file_path || payload.filePath || payload.path || '').trim(); + if (path.length === 0 || path.endsWith('/') || path.endsWith('\\')) { + return undefined; + } + return new ToolFileReference(path, ToolFileReferenceResolver.basename(path)); + } + + private static payload(toolInput?: Object, inputPreview: string = ''): ToolFileInputPayload { + if (toolInput) { + try { + return JSON.parse(JSON.stringify(toolInput)) as ToolFileInputPayload; + } catch (_err) { + } + } + const preview = inputPreview.trim(); + if (preview.length > 0 && preview.indexOf('{') === 0) { + try { + return JSON.parse(preview) as ToolFileInputPayload; + } catch (_err) { + } + } + return {}; + } + + private static supportsTool(toolName: string): boolean { + const normalized = toolName.replace(/[\s-]/g, '_').toLowerCase(); + return '|read|read_file|write|write_file|create|create_file|edit|edit_file|file_edit|strreplace|str_replace|str_replace_editor|replace|replace_file|update_file|'.indexOf(`|${normalized}|`) >= 0; + } + + private static basename(path: string): string { + let normalized = path.replace(/^computer:\/\//, '').replace(/^file:\/\//, '').replace(/\\/g, '/'); + const hash = normalized.lastIndexOf('#'); + if (hash > 0) { + normalized = normalized.slice(0, hash); + } + normalized = normalized.replace(/:\d+(?:-\d+)?$/, ''); + const parts = normalized.split('/'); + return parts[parts.length - 1] || normalized || 'file'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets new file mode 100644 index 0000000000..dda3d5aa2c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatCloudConfigPolicy.ets @@ -0,0 +1,119 @@ +import { GeneralChatRuntimeModelConfig } from './GeneralChatConfigStore'; + +interface CloudModelAuthWire { + type?: string; +} + +interface CloudModelWire { + id?: string; + name?: string; + provider?: string; + model_name?: string; + base_url?: string; + api_key?: string; + enabled?: boolean; + category?: string; + auth?: CloudModelAuthWire; +} + +interface CloudDefaultModelsWire { + primary?: string; +} + +interface CloudAiConfigWire { + models?: CloudModelWire[]; + default_models?: CloudDefaultModelsWire; +} + +interface CloudGlobalConfigWire { + ai?: CloudAiConfigWire; +} + +interface CloudSettingsPayloadWire { + config?: CloudGlobalConfigWire; + ai?: CloudAiConfigWire; +} + +/** Maps the shared encrypted account settings payload to the smaller HarmonyOS chat contract. */ +export class GeneralChatCloudConfigPolicy { + static models(payload: string): GeneralChatRuntimeModelConfig[] { + let parsed: CloudSettingsPayloadWire; + try { + parsed = JSON.parse(payload) as CloudSettingsPayloadWire; + } catch (_err) { + return []; + } + const ai = parsed.config?.ai || parsed.ai; + const models = ai?.models || []; + const primaryId = ai?.default_models?.primary || ''; + const ordered: CloudModelWire[] = []; + if (primaryId.length > 0) { + models.forEach((model: CloudModelWire) => { + if (model.id === primaryId) { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + } + }); + } + models.forEach((model: CloudModelWire) => { + if (model.category === 'general_chat') { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + } + }); + models.forEach((model: CloudModelWire) => { + GeneralChatCloudConfigPolicy.pushCompatible(ordered, model); + }); + return ordered.map((model: CloudModelWire): GeneralChatRuntimeModelConfig => { + const sourceId = GeneralChatCloudConfigPolicy.stringValue(model.id).trim() || + `${GeneralChatCloudConfigPolicy.stringValue(model.provider)}:${GeneralChatCloudConfigPolicy.stringValue(model.model_name)}`; + const modelName = GeneralChatCloudConfigPolicy.stringValue(model.model_name).trim(); + return { + modelId: `cloud:${sourceId}`, + name: GeneralChatCloudConfigPolicy.stringValue(model.name).trim() || modelName, + provider: GeneralChatCloudConfigPolicy.stringValue(model.provider).trim() || 'account', + apiUrl: GeneralChatCloudConfigPolicy.normalizedApiUrl(model), + apiKey: GeneralChatCloudConfigPolicy.stringValue(model.api_key).trim(), + modelName + }; + }); + } + + static selectModel(payload: string): GeneralChatRuntimeModelConfig | undefined { + const models = GeneralChatCloudConfigPolicy.models(payload); + return models.length > 0 ? models[0] : undefined; + } + + private static pushCompatible(target: CloudModelWire[], model: CloudModelWire): void { + if (!GeneralChatCloudConfigPolicy.isCompatible(model)) { + return; + } + const id = GeneralChatCloudConfigPolicy.stringValue(model.id); + const duplicate = target.some((candidate: CloudModelWire): boolean => { + return GeneralChatCloudConfigPolicy.stringValue(candidate.id) === id; + }); + if (!duplicate) { + target.push(model); + } + } + + private static isCompatible(model: CloudModelWire): boolean { + if (model.enabled !== true || model.auth?.type === 'subscription') { + return false; + } + return GeneralChatCloudConfigPolicy.stringValue(model.base_url).trim().length > 0 && + GeneralChatCloudConfigPolicy.stringValue(model.model_name).trim().length > 0 && + GeneralChatCloudConfigPolicy.stringValue(model.api_key).trim().length > 0; + } + + private static normalizedApiUrl(model: CloudModelWire): string { + const baseUrl = GeneralChatCloudConfigPolicy.stringValue(model.base_url).trim().replace(/\/+$/, ''); + const provider = GeneralChatCloudConfigPolicy.stringValue(model.provider).trim().toLowerCase(); + if (provider === 'anthropic' && baseUrl.indexOf('openbitfun.com') < 0 && !baseUrl.endsWith('/v1/messages')) { + return `${baseUrl}/v1/messages`; + } + return baseUrl; + } + + private static stringValue(value?: string): string { + return typeof value === 'string' ? value : ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets index 3df2ce907d..84f49f2316 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatConfigStore.ets @@ -1,5 +1,6 @@ import { preferences } from '@kit.ArkData'; import { huks } from '@kit.UniversalKeystoreKit'; +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { Encoding } from '../Encoding'; import { GeneralChatTokenProvider } from './GeneralChatHttpTransport'; @@ -10,6 +11,8 @@ const MODEL_NAME_KEY = 'model_name'; const API_KEY_CIPHER_KEY = 'api_key_cipher'; const API_KEY_IV_KEY = 'api_key_iv'; const HUKS_ALIAS = 'bitfun_general_chat_api_key'; +const SELECTED_MODEL_ID_KEY = 'selected_model_id'; +export const GENERAL_CHAT_LOCAL_MODEL_ID = 'local-general-chat'; export interface GeneralChatConfigSnapshot { apiUrl: string; @@ -24,6 +27,22 @@ export interface GeneralChatConfigUpdate { clearApiKey: boolean; } +export interface GeneralChatRuntimeModelConfig { + modelId: string; + name: string; + provider: string; + apiUrl: string; + modelName: string; + apiKey: string; +} + +export class GeneralChatModelSelectionPolicy { + static shouldActivateSavedLocalModel(catalog: RemoteModelCatalog): boolean { + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + return !catalog.models.some((model: RemoteModelConfig): boolean => model.id === selectedModelId); + } +} + export class GeneralChatConfigValidator { static validate(update: GeneralChatConfigUpdate, hasExistingApiKey: boolean): string { const apiUrl = update.apiUrl.trim(); @@ -53,9 +72,13 @@ export class GeneralChatConfigValidator { export class GeneralChatConfigStore implements GeneralChatTokenProvider { private store?: preferences.Preferences; + private accountModels: GeneralChatRuntimeModelConfig[] = []; + private preferredModelId: string = GENERAL_CHAT_LOCAL_MODEL_ID; + private catalogRevision: number = 1; async init(context: Context): Promise { this.store = await preferences.getPreferences(context, STORE_NAME); + this.preferredModelId = await this.getString(SELECTED_MODEL_ID_KEY) || GENERAL_CHAT_LOCAL_MODEL_ID; return this.snapshot(); } @@ -112,6 +135,92 @@ export class GeneralChatConfigStore implements GeneralChatTokenProvider { return this.decryptApiKey(cipherText, iv); } + replaceAccountModels(models: GeneralChatRuntimeModelConfig[]): void { + this.accountModels = models.map((model: GeneralChatRuntimeModelConfig): GeneralChatRuntimeModelConfig => ({ + modelId: model.modelId, + name: model.name, + provider: model.provider, + apiUrl: model.apiUrl, + modelName: model.modelName, + apiKey: model.apiKey + })); + this.catalogRevision += 1; + } + + async modelCatalog(): Promise { + const local = await this.snapshot(); + const models: RemoteModelConfig[] = []; + if (GeneralChatConfigStore.isComplete(local)) { + models.push({ + id: GENERAL_CHAT_LOCAL_MODEL_ID, + name: local.modelName, + provider: 'local', + base_url: local.apiUrl, + model_name: local.modelName, + enabled: true, + capabilities: ['text_chat'] + }); + } + this.accountModels.forEach((model: GeneralChatRuntimeModelConfig) => { + models.push({ + id: model.modelId, + name: model.name || model.modelName, + provider: model.provider || 'account', + base_url: model.apiUrl, + model_name: model.modelName, + enabled: true, + capabilities: ['text_chat'] + }); + }); + const selectedModelId = this.effectiveSelectedModelId(models); + return { + version: this.catalogRevision, + models, + default_models: { primary: selectedModelId || undefined }, + session_model_id: selectedModelId || undefined + }; + } + + async selectModel(modelId: string): Promise { + const catalog = await this.modelCatalog(); + if (!catalog.models.some((model: RemoteModelConfig): boolean => model.id === modelId)) { + return false; + } + this.preferredModelId = modelId; + const store = this.requireStore(); + await store.put(SELECTED_MODEL_ID_KEY, modelId); + await store.flush(); + this.catalogRevision += 1; + return true; + } + + async selectLocalModel(): Promise { + this.preferredModelId = GENERAL_CHAT_LOCAL_MODEL_ID; + const store = this.requireStore(); + await store.put(SELECTED_MODEL_ID_KEY, GENERAL_CHAT_LOCAL_MODEL_ID); + await store.flush(); + this.catalogRevision += 1; + } + + async activeSnapshot(): Promise { + const selectedModelId = this.effectiveSelectedModelId((await this.modelCatalog()).models); + const accountModel = this.accountModels.find((model: GeneralChatRuntimeModelConfig): boolean => { + return model.modelId === selectedModelId; + }); + if (accountModel) { + return { apiUrl: accountModel.apiUrl, modelName: accountModel.modelName, hasApiKey: true }; + } + return this.snapshot(); + } + + async activeAccessToken(): Promise { + const selectedModelId = this.effectiveSelectedModelId((await this.modelCatalog()).models); + const accountModel = this.accountModels.find((model: GeneralChatRuntimeModelConfig): boolean => { + return model.modelId === selectedModelId; + }); + return accountModel ? accountModel.apiKey : this.accessToken(); + } + private async encryptApiKey(apiKey: string): Promise { await this.ensureHuksKey(); const iv = Encoding.randomBytes(16); @@ -199,6 +308,18 @@ export class GeneralChatConfigStore implements GeneralChatTokenProvider { return this.store; } + private effectiveSelectedModelId(models: RemoteModelConfig[]): string { + if (models.some((model: RemoteModelConfig): boolean => model.id === this.preferredModelId)) { + return this.preferredModelId; + } + const local = models.find((model: RemoteModelConfig): boolean => model.id === GENERAL_CHAT_LOCAL_MODEL_ID); + return local ? local.id : (models.length > 0 ? models[0].id : ''); + } + + private static isComplete(snapshot: GeneralChatConfigSnapshot): boolean { + return snapshot.apiUrl.trim().length > 0 && snapshot.modelName.trim().length > 0 && snapshot.hasApiKey; + } + private static normalizeBaseUrl(value: string): string { const trimmed = value.trim(); return trimmed.endsWith('/') ? trimmed.slice(0, trimmed.length - 1) : trimmed; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets index 3726b6b3b0..c5faf7efaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/ModelProviderGeneralChatAdapter.ets @@ -212,8 +212,8 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { } async generateTitle(firstMessage: string, assistantMessage: string): Promise { - const config = await this.configStore.snapshot(); - const apiKey = (await this.configStore.accessToken()).trim(); + const config = await this.configStore.activeSnapshot(); + const apiKey = (await this.configStore.activeAccessToken()).trim(); if (config.apiUrl.length === 0 || config.modelName.length === 0 || apiKey.length === 0) { return ''; } @@ -264,8 +264,8 @@ export class ModelProviderGeneralChatAdapter implements GeneralChatPort { if (images.length > 0) { throw new Error(RemoteI18n.t('generalChat.imageNotSupported')); } - const config = await this.configStore.snapshot(); - const apiKey = (await this.configStore.accessToken()).trim(); + const config = await this.configStore.activeSnapshot(); + const apiKey = (await this.configStore.activeAccessToken()).trim(); if (config.apiUrl.length === 0 || config.modelName.length === 0 || apiKey.length === 0) { throw new Error(RemoteI18n.t('generalChat.modelNotConfigured')); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index 59a3b7b158..33f39ae8e9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -5,7 +5,8 @@ "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ - "phone" + "phone", + "tablet" ], "deliveryWithInstall": true, "installationFree": false, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 3c712962da..124f69ff32 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -3,6 +3,118 @@ { "name": "start_window_background", "value": "#FFFFFF" + }, + { + "name": "page_bg", + "value": "#FDFDFB" + }, + { + "name": "ink", + "value": "#171717" + }, + { + "name": "muted", + "value": "#706F6A" + }, + { + "name": "subtle", + "value": "#A5A39B" + }, + { + "name": "line", + "value": "#E9E7E2" + }, + { + "name": "card", + "value": "#FFFFFF" + }, + { + "name": "accent", + "value": "#111111" + }, + { + "name": "file_link", + "value": "#2563EB" + }, + { + "name": "primary_action", + "value": "#111111" + }, + { + "name": "primary_action_text", + "value": "#FFFFFF" + }, + { + "name": "connect_hero_bg", + "value": "#E6EDFF" + }, + { + "name": "connect_hero_accent", + "value": "#9DB4FF" + }, + { + "name": "connect_hero_secondary", + "value": "#C9C5FF" + }, + { + "name": "connect_hero_surface", + "value": "#F8FAFF" + }, + { + "name": "soft", + "value": "#F4F3F0" + }, + { + "name": "floating_panel_bg", + "value": "#F7F7F5" + }, + { + "name": "green", + "value": "#27C46A" + }, + { + "name": "red", + "value": "#E04F4F" + }, + { + "name": "code_line_number", + "value": "#AAA69D" + }, + { + "name": "code_keyword", + "value": "#8F3F71" + }, + { + "name": "code_string", + "value": "#477A4A" + }, + { + "name": "code_number", + "value": "#9A5B13" + }, + { + "name": "code_comment", + "value": "#7A8078" + }, + { + "name": "code_function", + "value": "#2C6693" + }, + { + "name": "code_type", + "value": "#865A20" + }, + { + "name": "code_constant", + "value": "#A04444" + }, + { + "name": "code_property", + "value": "#466D78" + }, + { + "name": "code_target_bg", + "value": "#FFF1BE" } ] -} \ No newline at end of file +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 79b11c2747..39e3e9d2c5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -3,6 +3,118 @@ { "name": "start_window_background", "value": "#000000" + }, + { + "name": "page_bg", + "value": "#151514" + }, + { + "name": "ink", + "value": "#F4F3EF" + }, + { + "name": "muted", + "value": "#AAA8A0" + }, + { + "name": "subtle", + "value": "#77756E" + }, + { + "name": "line", + "value": "#363531" + }, + { + "name": "card", + "value": "#252522" + }, + { + "name": "accent", + "value": "#5B5954" + }, + { + "name": "file_link", + "value": "#60A5FA" + }, + { + "name": "primary_action", + "value": "#454540" + }, + { + "name": "primary_action_text", + "value": "#FFFFFF" + }, + { + "name": "connect_hero_bg", + "value": "#2B2B29" + }, + { + "name": "connect_hero_accent", + "value": "#4A4944" + }, + { + "name": "connect_hero_secondary", + "value": "#3C3B38" + }, + { + "name": "connect_hero_surface", + "value": "#252522" + }, + { + "name": "soft", + "value": "#2D2C28" + }, + { + "name": "floating_panel_bg", + "value": "#1E1E1C" + }, + { + "name": "green", + "value": "#3BD47B" + }, + { + "name": "red", + "value": "#FF6B6B" + }, + { + "name": "code_line_number", + "value": "#77756E" + }, + { + "name": "code_keyword", + "value": "#D99AC4" + }, + { + "name": "code_string", + "value": "#9BCB9D" + }, + { + "name": "code_number", + "value": "#E3B36D" + }, + { + "name": "code_comment", + "value": "#96958D" + }, + { + "name": "code_function", + "value": "#8CBCE0" + }, + { + "name": "code_type", + "value": "#D5B27F" + }, + { + "name": "code_constant", + "value": "#E79A9A" + }, + { + "name": "code_property", + "value": "#9CC8D0" + }, + { + "name": "code_target_bg", + "value": "#5A4E24" } ] -} \ No newline at end of file +} diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 25b3dc5c9f..1af9cec0d6 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,8 +1,13 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; +import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; +import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { + externalLinks: string[] = []; + externalLinkResult: boolean = true; + attach(_context: Context, _uiContext: UIContext): void { } @@ -17,13 +22,18 @@ class FakeAppRootHost implements AppRootHostPort { showToast(_message: string, _duration: number): boolean { return false; } + + async openExternalLink(link: string): Promise { + this.externalLinks.push(link); + return this.externalLinkResult; + } } class TestAppRootRuntime extends AppRootRuntime { stopGeneralChatStreamCalls: number = 0; - constructor() { - super(new FakeAppRootHost()); + constructor(host: AppRootHostPort = new FakeAppRootHost()) { + super(host); } stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { @@ -49,5 +59,71 @@ export default function appRootLifecycleUnitTest() { expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); }); + + it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { + const host = new FakeAppRootHost(); + const runtime = new TestAppRootRuntime(host); + + runtime.openFilePreview( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/docs', 'docs') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(host.externalLinks.length).assertEqual(1); + expect(host.externalLinks[0]).assertEqual('https://example.com/docs'); + expect(runtime.filePreviewState.visible).assertFalse(); + }); + + it('closes preview before applying conversation navigation back', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + expect(runtime.handleNavigationBack(AppRoute.RemoteChat)).assertTrue(); + expect(runtime.filePreviewState.visible).assertFalse(); + expect(runtime.handleRootBack()).assertFalse(); + }); + + it('invalidates and closes preview when the remote target changes', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + runtime.invalidateFilePreviewTarget(); + + expect(runtime.filePreviewState.visible).assertFalse(); + }); + + it('closes preview only when the active remote session ownership changes', 0, () => { + const runtime = new TestAppRootRuntime(); + runtime.remotePageState.setActiveSession({ + sessionId: 'session-1', + title: 'Session 1', + workspacePath: '/workspace', + agentType: 'code' + }); + runtime.filePreviewState.begin(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 + )); + + runtime.applyRemoteActiveSession({ + sessionId: 'session-1', + title: 'Renamed session', + workspacePath: '/workspace', + agentType: 'code' + }); + expect(runtime.filePreviewState.visible).assertTrue(); + + runtime.applyRemoteActiveSession({ + sessionId: 'session-2', + title: 'Session 2', + workspacePath: '/workspace', + agentType: 'code' + }); + expect(runtime.filePreviewState.visible).assertFalse(); + }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index b5cbe9ef20..1139637387 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -83,5 +83,13 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps same-route state updates from rebuilding navigation', 0, () => { + const shell = new AppShellViewModel(); + shell.pushRoute(AppRoute.RemoteHome); + shell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); + expect(shell.navigationStack.getAllPathName().length).assertEqual(1); + }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 3be9a16c59..3fbd4c6b56 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -177,6 +177,13 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertTrue(); expect(state.showConnectSheet).assertTrue(); + state.openSettings('account'); + expect(state.showSettings).assertTrue(); + expect(state.settingsMode).assertEqual('account'); + state.leaveSettings(); + expect(state.showSettings).assertTrue(); + expect(state.settingsMode).assertEqual('general'); + state.closeGlobalSurfaces(); expect(state.showSidebar).assertFalse(); expect(state.showSettings).assertFalse(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 939ef397a1..68a3341a7f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -109,6 +109,7 @@ import { RemoteImageContext, RemoteQuestionAnswerPayload, RemoteModelCatalog, + ReadFileChunkResult, ReadFileResult, RemoteSession, SelectedImageAttachment, @@ -655,14 +656,29 @@ export class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { infoRequests: string[] = []; readRequests: string[] = []; shouldFailRead: boolean = false; + fileInfo: FileInfo = { + name: 'README.md', + size: 4096, + mimeType: 'text/plain' + }; + chunkResult: ReadFileChunkResult = { + name: 'README.md', + contentBase64: 'cmVhZG1l', + offset: 0, + chunkSize: 6, + totalSize: 6, + mimeType: 'text/plain' + }; + fileReadResult: ReadFileResult = { + name: 'README.md', + contentBase64: 'cmVhZG1l', + mimeType: 'text/plain', + size: 4096 + }; async getFileInfo(path: string, sessionId?: string): Promise { this.infoRequests.push(`${path}|${sessionId || ''}`); - return { - name: 'README.md', - size: 4096, - mimeType: 'text/plain' - }; + return this.fileInfo; } async readFile( @@ -677,11 +693,26 @@ export class FakeRemoteFileDownloadClient implements RemoteFileDownloadClient { if (this.shouldFailRead) { throw new Error('Expected download failure.'); } + return this.fileReadResult; + } + + async readFileChunk( + path: string, + offset: number, + limit: number, + sessionId?: string + ): Promise { + this.readRequests.push(`${path}|${sessionId || ''}|${offset}|${limit}`); + if (this.shouldFailRead) { + throw new Error('Expected download failure.'); + } return { - name: 'README.md', - contentBase64: 'cmVhZG1l', - mimeType: 'text/plain', - size: 4096 + name: this.chunkResult.name, + contentBase64: this.chunkResult.contentBase64, + offset, + chunkSize: this.chunkResult.chunkSize, + totalSize: this.chunkResult.totalSize, + mimeType: this.chunkResult.mimeType }; } } diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index 402228292b..eaf64f2b06 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -47,7 +47,17 @@ import { } from '../main/ets/services/general-chat/ModelProviderGeneralChatAdapter'; import { ModelProviderSseParser } from '../main/ets/services/general-chat/ModelProviderSseParser'; import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGeneralChatAdapter'; -import { MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { MarkdownParseCache, MarkdownParser } from '../main/ets/services/MarkdownParser'; +import { + ToolFileInputPayload, + ToolFileReferenceResolver +} from '../main/ets/services/ToolFileReferenceResolver'; +import { + CodeSyntaxHighlightCache, + CodeSyntaxHighlighter, + CodeSyntaxToken, + CodeSyntaxTokenKind +} from '../main/ets/services/CodeSyntaxHighlighter'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; @@ -59,6 +69,14 @@ import { RemoteFileDownloadController, RemoteFileDownloadScheduler } from '../main/ets/services/RemoteFileDownloadController'; +import { FileReferenceKind, FileTargetResolver } from '../main/ets/services/FileTargetResolver'; +import { FilePreviewPolicy } from '../main/ets/services/FilePreviewPolicy'; +import { FilePreviewErrorPolicy } from '../main/ets/services/FilePreviewErrorPolicy'; +import { + MessageFileReferenceProjectionCache, + MessageFileReferenceProjector +} from '../main/ets/services/MessageFileReferenceProjector'; +import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -82,17 +100,35 @@ import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/Voi import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { + FilePreviewPhase, + FilePreviewRendererKind, + FilePreviewState +} from '../main/ets/pages/state/FilePreviewState'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { + FilePreviewPlacement, + FilePreviewPlacementPolicy +} from '../main/ets/pages/state/FilePreviewPlacementPolicy'; import { ConversationLayoutCrease, ConversationLayoutPolicy } from '../main/ets/pages/state/ConversationLayoutPolicy'; +import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/state/SessionActionPolicy'; +import { ConversationSessionFilterPolicy } from '../main/ets/pages/state/ConversationSessionFilterPolicy'; +import { ConversationModelPresentationPolicy } from '../main/ets/pages/state/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../main/ets/pages/components/ChatComposerCapabilities'; import { ChatSurface } from '../main/ets/pages/components/ChatSurface'; import { ConversationViewContract } from '../main/ets/pages/components/ConversationViewContract'; +import { + ConversationUiModel, + ConversationUiModelCatalog +} from '../main/ets/pages/components/ConversationUiModels'; import { AppNavigationBackAction, AppNavigationPathSpec, @@ -170,7 +206,606 @@ import { FakeGeneralChatConfigStore } from './LocalTestFixtures'; +class BlockingFileInfoClient extends FakeRemoteFileDownloadClient { + private infoResolver?: (info: FileInfo) => void; + + async getFileInfo(path: string, sessionId?: string): Promise { + this.infoRequests.push(`${path}|${sessionId || ''}`); + return new Promise((resolve: (info: FileInfo) => void) => { + this.infoResolver = resolve; + }); + } + + resolveFileInfo(): void { + if (this.infoResolver) { + const resolve = this.infoResolver; + this.infoResolver = undefined; + resolve(this.fileInfo); + } + } +} + +class MultiBlockingFileInfoClient extends FakeRemoteFileDownloadClient { + private infoResolvers: Map void> = new Map void>(); + + async getFileInfo(path: string, sessionId?: string): Promise { + this.infoRequests.push(`${path}|${sessionId || ''}`); + return new Promise((resolve: (info: FileInfo) => void) => { + this.infoResolvers.set(path, resolve); + }); + } + + resolveFileInfo(path: string, info: FileInfo): void { + const resolve = this.infoResolvers.get(path); + if (resolve) { + this.infoResolvers.delete(path); + resolve(info); + } + } +} + export default function remoteControllersUnitTest() { + describe('ToolFileReferenceResolver', () => { + it('extracts structured single-file paths from supported tools', 0, () => { + const readInput: ToolFileInputPayload = { file_path: 'src/main.rs' }; + const read = ToolFileReferenceResolver.resolve('Read', readInput); + const edit = ToolFileReferenceResolver.resolve('edit_file', undefined, + '{"path":"computer://src/app.ts#L12-L20"}'); + + expect(read?.path || '').assertEqual('src/main.rs'); + expect(read?.label || '').assertEqual('main.rs'); + expect(edit?.path || '').assertEqual('computer://src/app.ts#L12-L20'); + expect(edit?.label || '').assertEqual('app.ts'); + }); + + it('rejects commands, directory tools and unstructured previews', 0, () => { + const fileInput: ToolFileInputPayload = { path: 'src/main.rs' }; + const directoryInput: ToolFileInputPayload = { path: 'src/' }; + expect(ToolFileReferenceResolver.resolve('Bash', fileInput) === undefined).assertTrue(); + expect(ToolFileReferenceResolver.resolve('LS', directoryInput) === undefined).assertTrue(); + expect(ToolFileReferenceResolver.resolve('Read', undefined, 'src/main.rs') === undefined).assertTrue(); + }); + }); + + describe('CodeSyntaxHighlighter', () => { + it('colors common code tokens while preserving numbered source text', 0, () => { + const source = 'const answer = 42;\n// note\nfunction run() { return "ok"; }'; + const tokens = CodeSyntaxHighlighter.tokenize(source, 'server.js'); + const rendered = tokens.map((token: CodeSyntaxToken): string => token.text).join(''); + const kinds = tokens.map((token: CodeSyntaxToken): CodeSyntaxTokenKind => token.kind); + + expect(rendered).assertEqual('1 const answer = 42;\n2 // note\n3 function run() { return "ok"; }'); + expect(kinds.indexOf(CodeSyntaxTokenKind.Keyword) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Number) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Comment) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.Function) >= 0).assertTrue(); + expect(kinds.indexOf(CodeSyntaxTokenKind.String) >= 0).assertTrue(); + }); + + it('falls back to one plain span for bounded rendering of large files', 0, () => { + const tokens = CodeSyntaxHighlighter.tokenize('x'.repeat(257 * 1024), 'large.rs'); + + expect(tokens.length).assertEqual(1); + expect(tokens[0].kind).assertEqual(CodeSyntaxTokenKind.Plain); + }); + + it('reuses token arrays until content or language identity changes', 0, () => { + const cache = new CodeSyntaxHighlightCache(); + const first = cache.tokensFor('const answer = 42;', 'main.ts'); + const same = cache.tokensFor('const answer = 42;', 'main.ts'); + const renamed = cache.tokensFor('const answer = 42;', 'main.txt'); + const changed = cache.tokensFor('const answer = 43;', 'main.txt'); + + expect(first === same).assertTrue(); + expect(same === renamed).assertFalse(); + expect(renamed === changed).assertFalse(); + }); + }); + + describe('MarkdownParser', () => { + it('caches parsed blocks until the Markdown source changes', 0, () => { + const cache = new MarkdownParseCache(); + const first = cache.blocksFor('# Title\n\nBody'); + const same = cache.blocksFor('# Title\n\nBody'); + const changed = cache.blocksFor('# Title\n\nUpdated'); + + expect(first === same).assertTrue(); + expect(same === changed).assertFalse(); + expect(MarkdownParser.formatInlineText('[file](README.md)')).assertEqual('file (README.md)'); + }); + }); + + describe('MessageFileReferenceProjector', () => { + it('deduplicates explicit desktop file cards by normalized target path', 0, () => { + const references = MessageFileReferenceProjector.project( + '[first](computer://src/main.rs#L4) and computer://src/main.rs:9, ' + + '[second](computer://src/lib.rs). [relative](README.md) `computer://src/hidden.rs`' + ); + + expect(references.length).assertEqual(2); + expect(references[0].remotePath).assertEqual('src/main.rs'); + expect(references[0].path).assertEqual('computer://src/main.rs#L4'); + expect(references[1].remotePath).assertEqual('src/lib.rs'); + }); + + it('caps cards and reuses projections until message text changes', 0, () => { + const source = 'computer://a.rs computer://b.rs computer://c.rs computer://d.rs computer://e.rs'; + const cache = new MessageFileReferenceProjectionCache(); + const first = cache.referencesFor(source); + const same = cache.referencesFor(source); + const changed = cache.referencesFor('computer://next.rs'); + + expect(first.length).assertEqual(4); + expect(first === same).assertTrue(); + expect(same === changed).assertFalse(); + expect(changed.length).assertEqual(1); + }); + }); + + describe('FileTargetResolver', () => { + it('resolves remote file references and line ranges', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 4); + const computer = FileTargetResolver.resolve('computer://src/main.rs#L42-L58', '', context); + const relative = FileTargetResolver.resolve('README.md:12-18', 'Readme', context); + const windows = FileTargetResolver.resolve('C:\\workspace\\main.cpp#L9', '', context); + + expect(computer.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(computer.target?.remotePath || '').assertEqual('src/main.rs'); + expect(computer.target?.lineStart || 0).assertEqual(42); + expect(computer.target?.lineEnd || 0).assertEqual(58); + expect(relative.target?.displayName || '').assertEqual('Readme'); + expect(relative.target?.lineStart || 0).assertEqual(12); + expect(windows.target?.remotePath || '').assertEqual('C:\\workspace\\main.cpp'); + expect(windows.target?.lineStart || 0).assertEqual(9); + expect(FileTargetResolver.matchesRemotePath('computer://src/main.rs#L42-L58', 'src/main.rs')).assertTrue(); + expect(FileTargetResolver.matchesRemotePath('src/main.rs:42', 'src/main.rs')).assertTrue(); + expect(FileTargetResolver.matchesRemotePath('src/other.rs', 'src/main.rs')).assertFalse(); + }); + + it('normalizes schemes, encoded paths, extensionless files and trailing punctuation', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 4); + const fileUrl = FileTargetResolver.resolve('file:///workspace/Makefile', '', context); + const encoded = FileTargetResolver.resolve('computer://docs/My%20File.md),', '', context); + const dockerfile = FileTargetResolver.resolve('/workspace/Dockerfile', '', context); + const dotfile = FileTargetResolver.resolve('.env', '', context); + const extensionless = FileTargetResolver.resolve('LICENSE', '', context); + const repeated = FileTargetResolver.resolve('file:///workspace/Makefile', '', context); + + expect(fileUrl.target?.remotePath || '').assertEqual('/workspace/Makefile'); + expect(encoded.target?.remotePath || '').assertEqual('docs/My File.md'); + expect(dockerfile.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(dotfile.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(extensionless.kind).assertEqual(FileReferenceKind.RemoteWorkspaceFile); + expect(repeated.target?.remotePath || '').assertEqual(fileUrl.target?.remotePath || ''); + }); + + it('classifies web, anchor and unsupported references without file targets', 0, () => { + const context = new FilePreviewTargetContext('session-1', '/workspace/BitFun', 1); + expect(FileTargetResolver.resolve('https://example.com', '', context).kind) + .assertEqual(FileReferenceKind.HttpUrl); + expect(FileTargetResolver.resolve('http://example.com', '', context).kind) + .assertEqual(FileReferenceKind.HttpUrl); + expect(FileTargetResolver.resolve('#section', '', context).kind) + .assertEqual(FileReferenceKind.Anchor); + expect(FileTargetResolver.resolve('mailto:test@example.com', '', context).kind) + .assertEqual(FileReferenceKind.UnsupportedScheme); + expect(FileTargetResolver.resolve('', '', context).kind) + .assertEqual(FileReferenceKind.Invalid); + }); + }); + + describe('FilePreviewState', () => { + it('targets linked lines and restores the last user scroll offset', 0, () => { + const state = new FilePreviewState(); + state.begin(new FilePreviewTarget( + 'src/main.rs#L42-L48', 'src/main.rs', 'main.rs', 'session-1', '/workspace', 1, 42, 48 + )); + + expect(state.initialScrollY()).assertEqual(741); + state.recordScroll(15, 380); + expect(state.initialScrollX()).assertEqual(15); + expect(state.initialScrollY()).assertEqual(380); + + state.begin(new FilePreviewTarget( + 'src/lib.rs', 'src/lib.rs', 'lib.rs', 'session-1', '/workspace', 1 + )); + expect(state.initialScrollY()).assertEqual(0); + }); + }); + + describe('RemoteFilePreviewController', () => { + it('centralizes bounded text and image size decisions in FilePreviewPolicy', 0, () => { + expect(FilePreviewPolicy.textReadLimit(128)).assertEqual(128); + expect(FilePreviewPolicy.textReadLimit(0)).assertEqual(2 * 1024 * 1024); + expect(FilePreviewPolicy.textReadLimit(3 * 1024 * 1024)).assertEqual(2 * 1024 * 1024); + expect(FilePreviewPolicy.canPreviewImage(12 * 1024 * 1024)).assertTrue(); + expect(FilePreviewPolicy.canPreviewImage(12 * 1024 * 1024 + 1)).assertFalse(); + }); + + it('maps file protocol failures to localized preview errors', 0, () => { + const missing = FilePreviewErrorPolicy.resolve(new Error('File not found: /workspace/missing.md')); + const unresolved = FilePreviewErrorPolicy.resolve(new Error('Remote file path could not be resolved: missing.md')); + const denied = FilePreviewErrorPolicy.resolve(new Error('Path outside workspace is restricted')); + const tooLarge = FilePreviewErrorPolicy.resolve(new Error('File too large (20 bytes, limit 10 bytes)')); + const unknown = FilePreviewErrorPolicy.resolve(new Error('Unexpected backend detail')); + + expect(missing.text).assertEqual(RemoteI18n.t('filePreview.notFound')); + expect(missing.retryable).assertTrue(); + expect(unresolved.text).assertEqual(RemoteI18n.t('filePreview.unavailable')); + expect(unresolved.retryable).assertTrue(); + expect(denied.text).assertEqual(RemoteI18n.t('filePreview.accessDenied')); + expect(denied.retryable).assertFalse(); + expect(tooLarge.text).assertEqual(RemoteI18n.t('filePreview.tooLarge')); + expect(tooLarge.retryable).assertFalse(); + expect(unknown.text).assertEqual(RemoteI18n.t('filePreview.loadFailed')); + expect(unknown.retryable).assertTrue(); + }); + + it('loads a bounded text preview without global busy state', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'main.rs', size: 16, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'main.rs', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('fn main() {}')), + offset: 0, + chunkSize: 12, + totalSize: 16, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 3); + const target = new FilePreviewTarget( + 'computer://src/main.rs', 'src/main.rs', 'main.rs', 'session-1', '/workspace/BitFun', 3 + ); + + await controller.open(target); + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.rendererKind).assertEqual(FilePreviewRendererKind.Text); + expect(state.textContent).assertEqual('fn main() {}'); + expect(state.truncated).assertTrue(); + expect(client.infoRequests[0]).assertEqual('src/main.rs|session-1'); + expect(client.readRequests[0].indexOf('src/main.rs|session-1|0|16') === 0).assertTrue(); + }); + + it('does not transfer unsupported binary files', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'archive.zip', size: 2048, mimeType: 'application/zip' }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'archive.zip', 'archive.zip', 'archive.zip', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(state.rendererKind).assertEqual(FilePreviewRendererKind.Unsupported); + expect(client.readRequests.length).assertEqual(0); + }); + + it('uses a bounded default range for unknown text sizes and rejects invalid UTF-8', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'unknown.txt', size: 0, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'unknown.txt', + contentBase64: Encoding.bytesToBase64(new Uint8Array([0xC3, 0x28])), + offset: 0, + chunkSize: 2, + totalSize: 0, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'unknown.txt', 'unknown.txt', 'unknown.txt', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(client.readRequests[0].indexOf('|0|2097152') >= 0).assertTrue(); + }); + + it('rejects an image when returned content exceeds the preview limit', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + client.fileInfo = { name: 'changed.png', size: 1024, mimeType: 'image/png' }; + client.fileReadResult = { + name: 'changed.png', + contentBase64: '', + mimeType: 'image/png', + size: 13 * 1024 * 1024 + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 2); + + await controller.open(new FilePreviewTarget( + 'changed.png', 'changed.png', 'changed.png', 'session-1', '/workspace', 2 + )); + + expect(state.phase).assertEqual(FilePreviewPhase.Unsupported); + expect(state.contentBase64).assertEqual(''); + }); + + it('loads Markdown and image content into their dedicated renderer states', 0, async () => { + const markdownClient = new FakeRemoteFileDownloadClient(); + markdownClient.fileInfo = { name: 'README.md', size: 7, mimeType: 'text/markdown' }; + markdownClient.chunkResult = { + name: 'README.md', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('# Title')), + offset: 0, + chunkSize: 7, + totalSize: 7, + mimeType: 'text/markdown' + }; + const markdownState = new FilePreviewState(); + const markdownController = new RemoteFilePreviewController(markdownClient, markdownState, () => true, () => 2); + await markdownController.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 2 + )); + + expect(markdownState.phase).assertEqual(FilePreviewPhase.Ready); + expect(markdownState.rendererKind).assertEqual(FilePreviewRendererKind.Markdown); + expect(markdownState.textContent).assertEqual('# Title'); + + const imageClient = new FakeRemoteFileDownloadClient(); + imageClient.fileInfo = { name: 'photo.png', size: 4, mimeType: 'image/png' }; + imageClient.fileReadResult = { + name: 'photo.png', + contentBase64: 'AAECAw==', + mimeType: 'image/png', + size: 4 + }; + const imageState = new FilePreviewState(); + const imageController = new RemoteFilePreviewController(imageClient, imageState, () => true, () => 2); + await imageController.open(new FilePreviewTarget( + 'photo.png', 'photo.png', 'photo.png', 'session-1', '/workspace', 2 + )); + + expect(imageState.phase).assertEqual(FilePreviewPhase.Ready); + expect(imageState.rendererKind).assertEqual(FilePreviewRendererKind.Image); + expect(imageState.contentBase64).assertEqual('AAECAw=='); + }); + + it('does not access the client for unavailable or invalid targets', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + let available = false; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => available, () => 2); + + await controller.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 2 + )); + available = true; + await controller.open(new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', '', '/workspace', 2 + )); + + expect(client.infoRequests.length).assertEqual(0); + expect(client.readRequests.length).assertEqual(0); + expect(state.visible).assertFalse(); + }); + + it('discards results after close or control-target changes', 0, async () => { + const client = new FakeRemoteFileDownloadClient(); + const state = new FilePreviewState(); + let epoch = 5; + const controller = new RemoteFilePreviewController(client, state, () => true, () => epoch); + const target = new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 5 + ); + + const pending = controller.open(target); + controller.close(); + epoch = 6; + await pending; + + expect(state.visible).assertFalse(); + expect(state.phase).assertEqual(FilePreviewPhase.Idle); + await controller.open(target); + expect(state.visible).assertFalse(); + }); + + it('turns an interrupted load into a retryable error after a transient disconnect', 0, async () => { + const client = new BlockingFileInfoClient(); + client.fileInfo = { name: 'README.md', size: 6, mimeType: 'text/plain' }; + client.chunkResult = { + name: 'README.md', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('readme')), + offset: 0, + chunkSize: 6, + totalSize: 6, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + let available = true; + const controller = new RemoteFilePreviewController(client, state, () => available, () => 4); + const target = new FilePreviewTarget( + 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 4 + ); + + const interrupted = controller.open(target); + expect(state.phase).assertEqual(FilePreviewPhase.Loading); + available = false; + client.resolveFileInfo(); + await interrupted; + + expect(state.visible).assertTrue(); + expect(state.phase).assertEqual(FilePreviewPhase.Error); + expect(state.errorText).assertEqual(RemoteI18n.t('filePreview.offline')); + expect(state.target.remotePath).assertEqual('README.md'); + + available = true; + const retried = controller.refresh(); + client.resolveFileInfo(); + await retried; + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.textContent).assertEqual('readme'); + }); + + it('keeps C when A and B metadata resolve after three rapid requests', 0, async () => { + const client = new MultiBlockingFileInfoClient(); + client.chunkResult = { + name: 'c.rs', + contentBase64: Encoding.bytesToBase64(Encoding.utf8ToBytes('fn c() {}')), + offset: 0, + chunkSize: 9, + totalSize: 9, + mimeType: 'text/plain' + }; + const state = new FilePreviewState(); + const controller = new RemoteFilePreviewController(client, state, () => true, () => 7); + const targetA = new FilePreviewTarget('a.rs', 'a.rs', 'a.rs', 'session-1', '/workspace', 7); + const targetB = new FilePreviewTarget('b.rs', 'b.rs', 'b.rs', 'session-1', '/workspace', 7); + const targetC = new FilePreviewTarget('c.rs', 'c.rs', 'c.rs', 'session-1', '/workspace', 7); + + const pendingA = controller.open(targetA); + const pendingB = controller.open(targetB); + const pendingC = controller.open(targetC); + client.resolveFileInfo('c.rs', { name: 'c.rs', size: 9, mimeType: 'text/plain' }); + await pendingC; + client.resolveFileInfo('b.rs', { name: 'b.rs', size: 9, mimeType: 'text/plain' }); + await pendingB; + client.resolveFileInfo('a.rs', { name: 'a.rs', size: 9, mimeType: 'text/plain' }); + await pendingA; + + expect(state.phase).assertEqual(FilePreviewPhase.Ready); + expect(state.target.remotePath).assertEqual('c.rs'); + expect(state.textContent).assertEqual('fn c() {}'); + expect(client.readRequests.length).assertEqual(1); + expect(client.readRequests[0].indexOf('c.rs|session-1') === 0).assertTrue(); + }); + + it('classifies markdown, images and executable SVG conservatively', 0, () => { + expect(RemoteFilePreviewController.rendererFor('README.md', 'text/markdown')) + .assertEqual(FilePreviewRendererKind.Markdown); + expect(RemoteFilePreviewController.rendererFor('Dockerfile', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('Makefile', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('LICENSE', 'application/octet-stream')) + .assertEqual(FilePreviewRendererKind.Text); + expect(RemoteFilePreviewController.rendererFor('photo.png', 'image/png')) + .assertEqual(FilePreviewRendererKind.Image); + expect(RemoteFilePreviewController.rendererFor('drawing.svg', 'image/svg+xml')) + .assertEqual(FilePreviewRendererKind.Unsupported); + }); + }); + + describe('FilePreviewPlacementPolicy', () => { + it('keeps compact devices full-page and regular tablets focused on chat plus preview', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, false, 1080, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + expect(FilePreviewPlacementPolicy.resolve(true, true, 900, [])) + .assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(false, true, 1400, [])) + .assertEqual(FilePreviewPlacement.Hidden); + }); + + it('uses triple pane only when all flat or crease-aligned panes meet minimum widths', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 1002, [])) + .assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1200, [ + new ConversationLayoutCrease(300, 8), + new ConversationLayoutCrease(700, 8) + ])).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1050, [ + new ConversationLayoutCrease(280, 8), + new ConversationLayoutCrease(620, 8) + ])).assertEqual(FilePreviewPlacement.WideFocusSplit); + }); + + it('returns renderable pane geometry that matches flat and crease-aligned decisions', 0, () => { + const flat = FilePreviewPlacementPolicy.resolveLayout(true, true, 1100, [], 344); + expect(flat.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(flat.masterPaneWidth).assertEqual(344); + expect(flat.masterConversationGap).assertEqual(1); + expect(flat.conversationPaneWidth).assertEqual(377); + expect(flat.conversationPreviewGap).assertEqual(1); + expect(flat.previewPaneWidth).assertEqual(377); + + const creased = FilePreviewPlacementPolicy.resolveLayout(true, true, 1260, [ + new ConversationLayoutCrease(320, 12), + new ConversationLayoutCrease(760, 16) + ], 344); + expect(creased.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(creased.masterPaneWidth).assertEqual(320); + expect(creased.masterConversationGap).assertEqual(12); + expect(creased.conversationPaneWidth).assertEqual(428); + expect(creased.conversationPreviewGap).assertEqual(16); + expect(creased.previewPaneWidth).assertEqual(484); + }); + + it('shrinks the flat master pane at the triple-pane threshold without narrowing content panes', 0, () => { + const threshold = FilePreviewPlacementPolicy.resolveLayout(true, true, 1002, [], 344); + expect(threshold.placement).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(threshold.masterPaneWidth).assertEqual(280); + expect(threshold.conversationPaneWidth).assertEqual(360); + expect(threshold.previewPaneWidth).assertEqual(360); + }); + + it('handles invalid, unsorted and rotation-sized geometry conservatively', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 900, [ + new ConversationLayoutCrease(-20, 8), + new ConversationLayoutCrease(880, 40) + ])).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(true, true, 1200, [ + new ConversationLayoutCrease(700, 8), + new ConversationLayoutCrease(300, 8) + ])).assertEqual(FilePreviewPlacement.WideTriplePane); + expect(FilePreviewPlacementPolicy.resolve(true, true, 760, [])) + .assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(FilePreviewPlacementPolicy.resolve(true, false, 760, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + }); + + it('keeps the focus layout full-page until both panes meet their minimum widths', 0, () => { + expect(FilePreviewPlacementPolicy.resolve(true, true, 720, [])) + .assertEqual(FilePreviewPlacement.CompactFullPage); + const threshold = FilePreviewPlacementPolicy.resolveLayout(true, true, 721, []); + expect(threshold.placement).assertEqual(FilePreviewPlacement.WideFocusSplit); + expect(threshold.conversationPaneWidth).assertEqual(360); + expect(threshold.previewPaneWidth).assertEqual(360); + }); + }); + + describe('RemoteCreateSessionState', () => { + it('keeps selected device and workspace labels aligned with refreshed choices', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'stale device label'); + state.selectWorkspace({ + name: 'stale workspace label', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }); + state.setDevices([ + { deviceId: 'desktop-a', deviceName: 'Desktop A', online: true }, + { deviceId: 'desktop-b', deviceName: 'Desktop B', online: true } + ]); + state.setWorkspaces([ + { name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' }, + { name: 'flashgrep', path: '/workspace/flashgrep', lastOpened: '', workspaceKind: 'normal' } + ]); + + expect(state.selectedDeviceName).assertEqual('Desktop B'); + expect(state.selectedWorkspaceName).assertEqual('BitFun'); + }); + + it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.setWorkspaces([{ + name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }]); + state.selectWorkspace({ + name: 'BitFun', path: '/workspace/BitFun', lastOpened: '', workspaceKind: 'normal' + }); + + const context = state.submissionContext(); + + expect(context.deviceId).assertEqual('desktop-b'); + expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('Claw'); + }); + }); + describe('RemoteFileDownloadController', () => { it('owns remote file download progress and delayed downloading marker cleanup', 0, async () => { const client = new FakeRemoteFileDownloadClient(); @@ -1044,19 +1679,221 @@ export default function remoteControllersUnitTest() { }); }); + describe('SessionActionPolicy', () => { + it('exposes only capabilities backed by each session scope', 0, () => { + const generalChat = SessionActionPolicy.resolve(SessionActionScope.General, 'chat', false); + const generalCode = SessionActionPolicy.resolve(SessionActionScope.General, 'code', false); + const remote = SessionActionPolicy.resolve(SessionActionScope.Remote, 'code', false); + const busyRemote = SessionActionPolicy.resolve(SessionActionScope.Remote, 'code', true); + + expect(generalChat.canArchive).assertTrue(); + expect(generalChat.canViewDetails).assertTrue(); + expect(generalChat.canExport).assertTrue(); + expect(generalChat.canDelete).assertTrue(); + expect(generalCode.canArchive).assertFalse(); + expect(generalCode.canExport).assertFalse(); + expect(generalCode.canDelete).assertTrue(); + expect(remote.canArchive).assertFalse(); + expect(remote.canViewDetails).assertTrue(); + expect(remote.canExport).assertFalse(); + expect(remote.canDelete).assertTrue(); + expect(busyRemote.canDelete).assertFalse(); + expect(busyRemote.canViewDetails).assertFalse(); + }); + }); + + describe('ConversationSessionFilterPolicy', () => { + const codeSession: RemoteSession = { + id: 'code-1', + title: 'Fix layout', + agentType: 'code', + status: 'idle', + updatedAt: '', + createdAt: '', + messageCount: 3, + workspacePath: '/workspace/bitfun' + }; + const chatSession: RemoteSession = { + id: 'chat-1', + title: 'Product notes', + agentType: 'claw', + status: 'active', + updatedAt: '', + createdAt: '', + messageCount: 2 + }; + + it('combines query, workspace, agent and status filters', 0, () => { + expect(ConversationSessionFilterPolicy.matches( + codeSession, 'layout', '', '/workspace/bitfun', 'code', 'idle', false + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, 'notes', '', '/workspace/bitfun', 'code', 'idle', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, '', '', '/workspace/other', 'code', 'idle', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.matches( + codeSession, '', '', '/workspace/bitfun/', 'code', 'idle', false + )).assertTrue(); + expect(ConversationSessionFilterPolicy.workspacePathsEqual( + '/workspace/bitfun/', '/workspace/bitfun' + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + chatSession, '', '/workspace/assistant', '', 'chat', 'active', true + )).assertTrue(); + expect(ConversationSessionFilterPolicy.matches( + chatSession, '', '/workspace/assistant', '/workspace/assistant', 'chat', 'active', true + )).assertFalse(); + }); + + it('excludes invalid and archived sessions before presentation grouping', 0, () => { + const archived: RemoteSession = { + id: 'archived-1', + title: 'Archived', + agentType: 'cowork', + status: 'archived', + updatedAt: '', + createdAt: '', + messageCount: 1 + }; + expect(ConversationSessionFilterPolicy.matches( + archived, '', '', '', '', '', false + )).assertFalse(); + expect(ConversationSessionFilterPolicy.agentGroup(chatSession, true)).assertEqual('chat'); + expect(ConversationSessionFilterPolicy.agentGroup(archived, false)).assertEqual('cowork'); + }); + }); + + describe('ConversationModelPresentationPolicy', () => { + const primaryModel: ConversationUiModel = { + id: 'anthropic/claude-sonnet-4', + name: 'Anthropic', + provider: 'anthropic', + base_url: '', + model_name: 'anthropic/claude-sonnet-4', + enabled: true, + capabilities: [] + }; + const fastModel: ConversationUiModel = { + id: 'openbitfun:gpt-5-mini', + name: 'Fast', + provider: 'openai', + base_url: '', + model_name: 'openbitfun:gpt-5-mini', + enabled: true, + capabilities: [] + }; + const disabledModel: ConversationUiModel = { + id: 'disabled-model', + name: 'Disabled', + provider: 'test', + base_url: '', + model_name: 'disabled-model', + enabled: false, + capabilities: [] + }; + const catalog: ConversationUiModelCatalog = { + version: 1, + models: [primaryModel, fastModel, disabledModel], + default_models: { primary: primaryModel.id }, + session_model_id: fastModel.id + }; + + it('filters disabled models and resolves selection precedence', 0, () => { + const enabled = ConversationModelPresentationPolicy.enabledModels(catalog); + expect(enabled.length).assertEqual(2); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, primaryModel.id)?.id) + .assertEqual(primaryModel.id); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, '')?.id) + .assertEqual(fastModel.id); + + const defaultCatalog: ConversationUiModelCatalog = { + version: 1, + models: catalog.models, + default_models: catalog.default_models + }; + expect(ConversationModelPresentationPolicy.selectedModel(defaultCatalog, '')?.id) + .assertEqual(primaryModel.id); + expect(ConversationModelPresentationPolicy.selectedModel(catalog, disabledModel.id)?.id) + .assertEqual(fastModel.id); + }); + + it('uses specific model names and preserves useful provider context', 0, () => { + expect(ConversationModelPresentationPolicy.primaryLabel(primaryModel, 'Model')) + .assertEqual('claude-sonnet-4'); + expect(ConversationModelPresentationPolicy.primaryLabel(fastModel, 'Model')) + .assertEqual('gpt-5-mini'); + expect(ConversationModelPresentationPolicy.secondaryLabel(fastModel, 'Model')) + .assertEqual('openai · Fast'); + }); + }); + describe('ConversationLayoutPolicy', () => { it('keeps folded and narrow surfaces compact', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail(480, false, false)).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, true, true)).assertFalse(); - expect(ConversationLayoutPolicy.useMasterDetail(900, false, true)).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(480, false, false, 'tablet', [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, true, true, 'tablet', [])).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail(900, false, true, 'phone', [ + new ConversationLayoutCrease(300, 8), + new ConversationLayoutCrease(600, 8) + ])).assertFalse(); }); - it('uses master-detail for media-query or width-qualified surfaces', 0, () => { - expect(ConversationLayoutPolicy.useMasterDetail(480, true, false)).assertTrue(); + it('uses master-detail for width-qualified tablets', 0, () => { expect(ConversationLayoutPolicy.useMasterDetail( ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, false, - false + false, + 'tablet', + [] + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + 480, + true, + false, + 'tablet', + [] + )).assertTrue(); + }); + + it('keeps phone, single-fold, and dual-screen foldables compact', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'phone', + [] + )).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'phone', + [new ConversationLayoutCrease(520, 8)] + )).assertFalse(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + true, + false, + 'tablet', + [new ConversationLayoutCrease(520, 8)] + )).assertFalse(); + }); + + it('uses master-detail for unfolded tri-fold surfaces', 0, () => { + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.EXTRA_WIDE_MIN_WIDTH, + false, + false, + 'phone', + [new ConversationLayoutCrease(352, 8), new ConversationLayoutCrease(712, 8)] + )).assertTrue(); + expect(ConversationLayoutPolicy.useMasterDetail( + ConversationLayoutPolicy.WIDE_LAYOUT_MIN_WIDTH, + false, + false, + 'phone', + [new ConversationLayoutCrease(230, 8), new ConversationLayoutCrease(480, 8)] )).assertTrue(); }); @@ -1071,6 +1908,8 @@ export default function remoteControllersUnitTest() { expect(geometry.isExtraWide).assertFalse(); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(516); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(860); expect(invalidCrease.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); }); @@ -1079,6 +1918,8 @@ export default function remoteControllersUnitTest() { expect(geometry.masterPaneWidth).assertEqual(ConversationLayoutPolicy.FALLBACK_MASTER_PANE_WIDTH); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(0); + expect(geometry.collapsedDetailContentOffset).assertEqual(0); + expect(geometry.collapsedDetailContentWidth).assertEqual(300); }); it('aligns the master boundary with the first usable fold crease', 0, () => { @@ -1114,6 +1955,8 @@ export default function remoteControllersUnitTest() { expect(geometry.isExtraWide).assertTrue(); expect(geometry.detailContentOffset).assertEqual(360); expect(geometry.detailContentWidth).assertEqual(360); + expect(geometry.collapsedDetailContentOffset).assertEqual(720); + expect(geometry.collapsedDetailContentWidth).assertEqual(360); }); it('selects the widest hinge-free detail band on asymmetric three-screen geometry', 0, () => { @@ -1123,6 +1966,8 @@ export default function remoteControllersUnitTest() { ); expect(geometry.detailContentOffset).assertEqual(0); expect(geometry.detailContentWidth).assertEqual(440); + expect(geometry.collapsedDetailContentOffset).assertEqual(360); + expect(geometry.collapsedDetailContentWidth).assertEqual(440); }); it('uses width as the extra-wide fallback when crease data is missing', 0, () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 2423f95116..2138e858e5 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,10 +20,12 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, - GeneralChatConfigValidator + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; import { GeneralChatDraftController, GeneralChatDraftScheduler } from '../main/ets/services/general-chat/GeneralChatDraftController'; import { GeneralChatDraftLifecycleController } from '../main/ets/services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatCloudConfigPolicy } from '../main/ets/services/general-chat/GeneralChatCloudConfigPolicy'; import { GeneralChatEventMapper } from '../main/ets/services/general-chat/GeneralChatEventMapper'; import { GeneralChatExportFormatter } from '../main/ets/services/general-chat/GeneralChatExportFormatter'; import { @@ -731,6 +733,73 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('GeneralChatModelSelectionPolicy', () => { + it('keeps an existing cloud selection when a local model is saved', 0, () => { + const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ + version: 4, + models: [{ + id: 'cloud:account-model', + name: 'Account model', + provider: 'openai', + base_url: 'https://chat.example.com/v1', + model_name: 'account-model', + enabled: true, + capabilities: [] + }], + default_models: { primary: 'cloud:account-model' }, + session_model_id: 'cloud:account-model' + }); + + expect(shouldActivateLocal).assertFalse(); + }); + + it('activates the first saved local model when no model was previously available', 0, () => { + const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ + version: 1, + models: [], + default_models: {} + }); + + expect(shouldActivateLocal).assertTrue(); + }); + }); + + describe('GeneralChatCloudConfigPolicy', () => { + it('orders the primary model first while retaining other compatible account models', 0, () => { + const payload = '{"config":{"ai":{"default_models":{"primary":"primary-model"},"models":[' + + '{"id":"fallback-model","provider":"openai","model_name":"fallback","base_url":"https://fallback.example.com/v1","api_key":"fallback-key","enabled":true,"category":"general_chat"},' + + '{"id":"primary-model","provider":"openai","model_name":"primary","base_url":"https://primary.example.com/v1/","api_key":"primary-key","enabled":true,"category":"code_specialized"}' + + ']}}}'; + + const models = GeneralChatCloudConfigPolicy.models(payload); + + expect(models.length).assertEqual(2); + expect(models[0].modelId).assertEqual('cloud:primary-model'); + expect(models[0].apiUrl).assertEqual('https://primary.example.com/v1'); + expect(models[0].modelName).assertEqual('primary'); + expect(models[0].apiKey).assertEqual('primary-key'); + expect(models[1].modelId).assertEqual('cloud:fallback-model'); + }); + + it('skips subscription models and adapts an Anthropic general-chat endpoint', 0, () => { + const payload = '{"ai":{"default_models":{"primary":"subscription-model"},"models":[' + + '{"id":"subscription-model","provider":"openai","model_name":"codex","base_url":"https://subscription.example.com","api_key":"unused","enabled":true,"category":"general_chat","auth":{"type":"subscription"}},' + + '{"id":"anthropic-model","provider":"anthropic","model_name":"claude","base_url":"https://api.anthropic.com/","api_key":"anthropic-key","enabled":true,"category":"general_chat"}' + + ']}}'; + + const selected = GeneralChatCloudConfigPolicy.selectModel(payload); + + expect(selected?.modelId).assertEqual('cloud:anthropic-model'); + expect(selected?.apiUrl).assertEqual('https://api.anthropic.com/v1/messages'); + }); + + it('returns no model for malformed or unusable cloud settings', 0, () => { + const unusable = '{"ai":{"models":[{"id":"disabled","enabled":false,"base_url":"https://api.example.com","model_name":"model","api_key":"key"}]}}'; + expect(GeneralChatCloudConfigPolicy.models('{not-json').length).assertEqual(0); + expect(GeneralChatCloudConfigPolicy.models(unusable).length).assertEqual(0); + }); + }); + describe('ModelProviderGeneralChatAdapter', () => { it('infers provider protocol and request URL without a protocol setting', 0, () => { const openBitFunProtocol = ModelProviderGeneralChatAdapter.resolveProtocol('https://api.openbitfun.com'); From 2fded8d533ac722ed6cc96066c85ca2964d5142d Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Wed, 5 Aug 2026 15:10:16 +0800 Subject: [PATCH 018/206] fix(harmonyos): refine compact navigation and remote presentation --- src/apps/mobile/harmonyos/AGENTS.md | 23 ++++++ .../main/ets/pages/components/AppShell.ets | 4 +- .../main/ets/pages/components/AppSidebar.ets | 34 +++++++-- .../pages/components/CompactMenuButton.ets | 28 +++++++ .../ets/pages/components/ConversationView.ets | 71 ++++++------------ .../pages/components/GeneralChatHeader.ets | 59 ++++++++++----- .../pages/components/RemoteActionsSheet.ets | 5 -- .../ets/pages/components/RemoteChatHeader.ets | 70 ++++++++++++----- .../ets/pages/components/RemoteHeader.ets | 35 +++++---- .../ets/pages/components/RemoteHomeView.ets | 8 +- .../media/remote_ref_sidebar_connected.png | Bin 2778 -> 3129 bytes 11 files changed, 220 insertions(+), 117 deletions(-) create mode 100644 src/apps/mobile/harmonyos/AGENTS.md create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md new file mode 100644 index 0000000000..b53cdd141c --- /dev/null +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -0,0 +1,23 @@ +# HarmonyOS App Instructions + +These rules apply to all changes under `src/apps/mobile/harmonyos`. + +## Visual reference fidelity + +- Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. +- Conversation header controls must use the approved `remote_ref_back` and `remote_ref_more` assets. Do not replace them with a system chevron or text such as `...` / bullet characters. +- Render monochrome reference assets in template mode and tint them with semantic theme colors such as `INK`. Never rely on the bitmap's original black or white pixels; the same control must remain legible in light and dark themes. +- Keep paired header controls on the same fixed touch-target size and optical alignment. A responsive layout may reposition a control, but must not silently change its icon geometry or visual weight. + +## Responsive interaction semantics + +- Wide and compact layouts must keep the same interaction meaning. Responsive presentation may change spacing and available width, but it must not turn a lightweight anchored action menu into a bottom sheet by default. +- Conversation-header overflow actions open from the top-right trigger as an anchored popover on both compact and wide layouts. Use a bottom sheet only when the content is a genuinely large or multi-step mobile workflow and the design explicitly calls for it. +- Anchor popovers to their actual trigger with `bindPopup` or the equivalent platform API. Do not emulate the anchor with unrelated page-level absolute positioning. +- Preserve auto-dismiss, outside-tap handling, accessibility labels, and a short enter/exit transition for anchored menus. + +## Theme and device verification + +- Use existing semantic colors from `Theme.ets`; do not hard-code a light-only foreground or surface color. +- For changes to navigation controls, menus, or responsive presentation, verify compact and wide behavior, light and dark theme legibility, and capture a real-device screenshot before completion when a device is connected. +- Run the smallest matching HarmonyOS build/check plus `pnpm run theme:color-audit:all` for theme or color-related changes. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index c5651b0255..1d71ccca67 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -1,5 +1,5 @@ import { AppShellState } from '../state/AppShellState'; -import { CARD, PAGE_BG } from './Theme'; +import { PAGE_BG } from './Theme'; const WIDE_SETTINGS_SHEET_MAX_WIDTH: number = 680; const WIDE_CONNECT_SHEET_MAX_WIDTH: number = 620; @@ -49,7 +49,7 @@ export struct AppShell { } .width('100%') .height('100%') - .backgroundColor(CARD) + .backgroundColor(PAGE_BG) .opacity(0.62) .transition(TransitionEffect.opacity(0) .animation({ duration: 210, curve: Curve.EaseOut })) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index c1bf484f3d..be4b21d0d9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -1,6 +1,6 @@ import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, FLOATING_PANEL_BG, INK, LINE, MUTED, SOFT, SUBTLE } from './Theme'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, SOFT, SUBTLE } from './Theme'; import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { SidebarToggleButton } from './SidebarToggleButton'; @@ -133,7 +133,7 @@ export struct AppSidebar { .width('100%') .height('100%') .padding({ left: 20, right: 20, top: 4, bottom: 16 }) - .backgroundColor(FLOATING_PANEL_BG) + .backgroundColor(PAGE_BG) .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @@ -503,11 +503,31 @@ export struct AppSidebar { @Builder RemoteGlyph() { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(24) - .fontColor([this.connectionState === 'connected' || this.connectionState === 'reconnecting' ? INK : MUTED]) - .width(35) - .height(34) + Stack({ alignContent: Alignment.Center }) { + if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { + Image($r('app.media.remote_ref_sidebar_connected')) + .width(35) + .height(34) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + Text('') + .width(8) + .height(8) + .backgroundColor(GREEN) + .borderRadius(4) + .position({ x: 24, y: 22 }) + } else { + Image($r('app.media.remote_logo')) + .width(34) + .height(34) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(MUTED) + } + } + .width(35) + .height(34) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets new file mode 100644 index 0000000000..ab6f189da4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CompactMenuButton.ets @@ -0,0 +1,28 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK } from './Theme'; + +@ComponentV2 +export struct CompactMenuButton { + @Param controlSize: number = 48; + @Event onOpen: () => void = () => {}; + + build() { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.gpt_home_menu_glyph')) + .width(22) + .height(14) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(this.controlSize) + .height(this.controlSize) + .backgroundColor(CARD) + .borderRadius(this.controlSize / 2) + .shadow({ radius: 16, color: '#12000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .onClick(() => { + this.onOpen(); + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 3b1e9d679b..6dbb9e2694 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -16,7 +16,7 @@ import { import { ComposerBar, ComposerPresentation } from './ComposerBar'; import { GeneralChatHeader } from './GeneralChatHeader'; import { RemoteChatHeader } from './RemoteChatHeader'; -import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; +import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; @ComponentV2 export struct ConversationView { @@ -137,10 +137,6 @@ export struct ConversationView { this.MenuBackdrop(() => { this.showQuickActions = false; }) this.QuickActionsMenu() } - if (this.showHeaderActions) { - this.MenuBackdrop(() => { this.showHeaderActions = false; }) - this.HeaderActionsMenu() - } } .width('100%') .height('100%') @@ -156,6 +152,10 @@ export struct ConversationView { showSidebarButton: this.showSidebarButton, showBackButton: this.showBackButton, showSidebarRestoreButton: this.showSidebarRestoreButton, + showActionsMenu: this.showHeaderActions, + actionsMenu: () => { + this.HeaderActionsPopover(); + }, onOpenSidebar: () => { this.onOpenSidebar(); }, @@ -168,6 +168,9 @@ export struct ConversationView { onOpenActions: () => { this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; + }, + onActionsMenuStateChange: (visible: boolean) => { + this.showHeaderActions = visible; } }) } else { @@ -177,6 +180,10 @@ export struct ConversationView { desktopName: this.desktopName, showBackButton: this.showBackButton, showSidebarRestoreButton: this.showSidebarRestoreButton, + showActionsMenu: this.showHeaderActions, + actionsMenu: () => { + this.HeaderActionsPopover(); + }, onBack: () => { this.onBack(); }, @@ -187,6 +194,9 @@ export struct ConversationView { this.showQuickActions = false; this.showHeaderActions = !this.showHeaderActions; }, + onActionsMenuStateChange: (visible: boolean) => { + this.showHeaderActions = visible; + }, onRenameSession: (title: string) => { this.onRenameSession(title); } @@ -435,55 +445,20 @@ export struct ConversationView { .animation({ duration: 220, curve: Curve.EaseOut })) } - @Builder - HeaderActionsMenu() { - if (this.composerPresentation === ComposerPresentation.Floating) { - this.HeaderActionsPopover() - } else { - this.HeaderActionsBottomSheet() - } - } - @Builder HeaderActionsPopover() { Column() { this.HeaderActionsContent() } - .width(330) - .padding({ left: 16, right: 16, top: 14, bottom: 14 }) - .backgroundColor(CARD) - .border({ width: 1, color: '#0D000000' }) - .borderRadius(20) - .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) - .position({ right: 18, top: 14 }) - .zIndex(6) - .transition(TransitionEffect.translate({ x: 18, y: -12 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) - } - - @Builder - HeaderActionsBottomSheet() { - Column() { - Text('') - .width(36) - .height(4) - .backgroundColor(LINE) - .borderRadius(2) - .margin({ bottom: 8 }) - this.HeaderActionsContent() - } - .width('100%') - .padding({ left: 16, right: 16, top: 10, bottom: 20 }) - .backgroundColor(CARD) - .border({ width: { top: 1 }, color: LINE }) - .borderRadius({ topLeft: 18, topRight: 18 }) - .position({ left: 0, bottom: 0 }) - .zIndex(6) - .alignItems(HorizontalAlign.Center) - .transition(TransitionEffect.translate({ x: 0, y: 24 }) + .width(292) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .backgroundColor(FLOATING_PANEL_BG) + .border({ width: 1, color: LINE }) + .borderRadius(16) + .shadow({ radius: 18, color: LINE, offsetY: 7 }) + .transition(TransitionEffect.translate({ x: 8, y: -8 }) .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) + .animation({ duration: 180, curve: Curve.EaseOut })) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index 91a4b120b6..e6b78fe044 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,5 +1,6 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -9,10 +10,13 @@ export struct GeneralChatHeader { @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; + @Param showActionsMenu: boolean = false; + @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onBack: () => void = () => {}; @Event onOpenActions: () => void = () => {}; + @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; build() { Row({ space: 8 }) { @@ -46,33 +50,28 @@ export struct GeneralChatHeader { }) } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(22) - .fontColor([INK]) + Image($r('app.media.remote_ref_back')) + .width(16) + .height(25) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) } .width(48) .height(48) .backgroundColor(CARD) .border({ width: 1, color: LINE }) .borderRadius(24) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { this.onBack(); }) } else if (this.showSidebarButton) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.line_3_horizontal')) - .fontSize(22) - .fontColor([INK]) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(24) - .accessibilityText(RemoteI18n.t('sidebar.more')) - .onClick(() => { - this.onOpenSidebar(); + CompactMenuButton({ + onOpen: () => { + this.onOpenSidebar(); + } }) } else { Blank().width(48).height(48) @@ -83,16 +82,34 @@ export struct GeneralChatHeader { private TrailingControl() { if (this.showActions) { Stack({ alignContent: Alignment.Center }) { - Text('•••') - .fontSize(13) - .fontColor(INK) + Image($r('app.media.remote_ref_more')) + .width(25) + .height(8) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) } .width(48) .height(48) .backgroundColor(CARD) .border({ width: 1, color: LINE }) .borderRadius(24) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('sidebar.more')) + .bindPopup(this.showActionsMenu, { + builder: () => { + this.actionsMenu(); + }, + placement: Placement.BottomRight, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + this.onActionsMenuStateChange(event.isVisible); + } + }) .onClick(() => { this.onOpenActions(); }) @@ -100,4 +117,8 @@ export struct GeneralChatHeader { Blank().width(48).height(48) } } + + @Builder + private EmptyBuilder() { + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets index 6ff4246ac9..e1fc4ec667 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets @@ -29,7 +29,6 @@ export struct RemoteActionsSheet { @Event onDisconnect: () => void = () => {}; @Event onClearPairing: () => void = () => {}; @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; - @Event onAddConnection: () => void = () => {}; @Event onOpenSettings: () => void = () => {}; @Event onOpenViewSettings: () => void = () => {}; @@ -80,10 +79,6 @@ export struct RemoteActionsSheet { duration: 1800 }); }) - this.IconRow('remote_actions_link', RemoteI18n.t('remote.menu.addConnection'), '', () => { - this.onAddConnection(); - this.onDismiss(); - }) this.IconRow('remote_actions_settings', RemoteI18n.t('remote.menu.settings'), '', () => { this.onDismiss(); this.onOpenSettings(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index adfd6853de..3dc9561ea1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -15,9 +15,12 @@ export struct RemoteChatHeader { @Param desktopName: string = ''; @Param showBackButton: boolean = true; @Param showSidebarRestoreButton: boolean = false; + @Param showActionsMenu: boolean = false; + @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onOpenActions: () => void = () => {}; + @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @Event onRenameSession: (title: string) => void = (_title: string) => {}; @Local showTitleEditor: boolean = false; @Local renameTitle: string = ''; @@ -62,21 +65,7 @@ export struct RemoteChatHeader { } .layoutWeight(1) .alignItems(HorizontalAlign.Center) - Stack({ alignContent: Alignment.Center }) { - Text('•••') - .fontSize(13) - .fontColor(INK) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - .accessibilityText(RemoteI18n.t('sidebar.more')) - .onClick(() => { - this.showTitleEditor = false; - this.onOpenActions(); - }) + this.ActionsControl() } .width('100%') .alignItems(VerticalAlign.Center) @@ -94,15 +83,19 @@ export struct RemoteChatHeader { }) } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.chevron_left')) - .fontSize(22) - .fontColor([INK]) + Image($r('app.media.remote_ref_back')) + .width(16) + .height(25) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) } .width(48) .height(48) .backgroundColor(CARD) .border({ width: 1, color: LINE }) .borderRadius(24) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { this.onBack(); @@ -112,6 +105,43 @@ export struct RemoteChatHeader { } } + @Builder + private ActionsControl() { + Stack({ alignContent: Alignment.Center }) { + Image($r('app.media.remote_ref_more')) + .width(25) + .height(8) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(48) + .height(48) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + .shadow({ radius: 10, color: LINE, offsetY: 3 }) + .accessibilityText(RemoteI18n.t('sidebar.more')) + .bindPopup(this.showActionsMenu, { + builder: () => { + this.actionsMenu(); + }, + placement: Placement.BottomRight, + popupColor: '#00000000', + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event) => { + this.onActionsMenuStateChange(event.isVisible); + } + }) + .onClick(() => { + this.showTitleEditor = false; + this.onOpenActions(); + }) + } + @Builder TitleEditor() { Row({ space: 8 }) { @@ -166,4 +196,8 @@ export struct RemoteChatHeader { return this.workspaceBranch.length > 0 ? `${brand} · ${this.workspaceBranch}` : brand; } + @Builder + private EmptyBuilder() { + } + } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets index 6c6819b035..29412ed5da 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets @@ -1,6 +1,7 @@ import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; import { CARD, GREEN, INK, MUTED, RED } from './Theme'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CompactMenuButton } from './CompactMenuButton'; @ComponentV2 export struct RemoteHeader { @@ -9,6 +10,7 @@ export struct RemoteHeader { @Param isLoading: boolean = false; @Event onOpenSidebar: () => void = () => {}; @Event onOpenActions: () => void = () => {}; + @Event onOpenDevices: () => void = () => {}; build() { Row() { @@ -42,10 +44,25 @@ export struct RemoteHeader { .fontColor(MUTED) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + SymbolGlyph($r('sys.symbol.chevron_down')) + .fontSize(11) + .fontColor([MUTED]) + .width(13) + .height(13) + .opacity(0.58) } + .height(28) + .padding({ left: 8, right: 8 }) + .borderRadius(10) + .accessibilityText(RemoteI18n.t('connect.accountDevicesTitle')) + .onClick(() => { + this.onOpenDevices(); + }) } .layoutWeight(1) + .height(52) .alignItems(HorizontalAlign.Center) + .justifyContent(FlexAlign.Center) this.MoreButton() } .width('100%') @@ -55,20 +72,10 @@ export struct RemoteHeader { @Builder private MenuButton() { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.line_3_horizontal')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) - .onClick(() => { - this.onOpenSidebar(); + CompactMenuButton({ + onOpen: () => { + this.onOpenSidebar(); + } }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets index fc2da92dc5..5059039e4d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets @@ -66,6 +66,9 @@ export struct RemoteHomeView { }, onOpenActions: () => { this.openRemoteActions(); + }, + onOpenDevices: () => { + this.onAddConnection(); } }) if (this.isInitialLoading()) { @@ -153,7 +156,7 @@ export struct RemoteHomeView { this.RemoteActionsLayer() } .width(330) - .height(390) + .height(292) .margin({ top: 4, right: 4 }) .transition(TransitionEffect.translate({ x: 18, y: -12 }) .combine(TransitionEffect.opacity(0)) @@ -223,9 +226,6 @@ export struct RemoteHomeView { onSortModeChange: (mode: string) => { this.onSortModeChange(mode); }, - onAddConnection: () => { - this.onAddConnection(); - }, onOpenSettings: () => { this.onOpenRemoteSettings(); }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png b/src/apps/mobile/harmonyos/entry/src/main/resources/base/media/remote_ref_sidebar_connected.png index 6d1e8d28daf1e7386675833375076ee4a5bfb25d..309bc9eee643d829ef5a266ccdc1877487a3c216 100644 GIT binary patch delta 3125 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 Z{{eS#F5mWZT5kXV002ovPDHLkV1gK?Ee-$x From f2e493818b66de5efa303c095e787adfe46ca132 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Wed, 5 Aug 2026 17:13:33 +0800 Subject: [PATCH 019/206] fix(harmonyos): dismiss stale retry actions --- .../pages/components/ChatMessageBubble.ets | 6 ++-- .../ets/pages/components/ChatTimeline.ets | 1 + .../ets/services/ChatTimelineProjector.ets | 25 ++++++++++--- .../general-chat/GeneralChatRepository.ets | 36 ++++++++++++++++--- .../src/test/ConversationStateUnit.test.ets | 18 ++++++++++ .../test/TransportAndGeneralChatUnit.test.ets | 15 ++++++++ 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 2235345a9a..b2b3e8531c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -46,6 +46,7 @@ export struct ChatMessageBubble { }; @Param isStreaming: boolean = false; @Param isFinalizing: boolean = false; + @Param showRetryAction: boolean = false; @Param isBusy: boolean = false; @Param downloadingFilePath: string = ''; @Param downloadedFilePath: string = ''; @@ -114,7 +115,7 @@ export struct ChatMessageBubble { .borderRadius(18) .alignItems(HorizontalAlign.Start) } - if (this.item.status === 'failed') { + if (this.item.status === 'failed' && this.showRetryAction) { Row({ space: 8 }) { Text(RemoteI18n.t('chat.sendFailed')) .fontSize(12) @@ -151,7 +152,8 @@ export struct ChatMessageBubble { if (this.item.images && this.item.images.length > 0) { this.MessageImages(this.item.images) } - if (this.item.status === 'failed' && (this.item.detail || '').trim().length > 0) { + if (this.item.status === 'failed' && this.showRetryAction && + (this.item.detail || '').trim().length > 0) { Row({ space: 8 }) { Text(RemoteI18n.t('generalChat.replyInterrupted')) .fontSize(12) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 7fabd07bd4..79ad92a1cc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -145,6 +145,7 @@ export struct ChatTimeline { item: toConversationUiMessage(item.message!), isStreaming: item.isStreaming, isFinalizing: item.isFinalizing, + showRetryAction: item.showRetryAction === true, isBusy: this.isBusy, downloadingFilePath: this.downloadingFilePath, downloadedFilePath: this.downloadedFilePath, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets index fc228c8b9b..05a894ef71 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatTimelineProjector.ets @@ -13,6 +13,7 @@ export interface ChatTimelineItem { message?: ChatMessage; isStreaming: boolean; isFinalizing: boolean; + showRetryAction?: boolean; } /** Shared render invalidation for both remote polling and general-chat streaming. */ @@ -66,7 +67,8 @@ export class ChatTimelineProjector { type: ChatTimelineProjector.messageItemType(message), message, isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; return item; }); @@ -77,7 +79,8 @@ export class ChatTimelineProjector { type: 'optimistic_user_message', message, isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; items.push(item); }); @@ -89,7 +92,8 @@ export class ChatTimelineProjector { type: 'assistant_live_turn', message: activeTurn, isStreaming: (activeTurn.status || '').toLowerCase() === 'active', - isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed' + isFinalizing: (activeTurn.status || '').toLowerCase() === 'completed', + showRetryAction: false }; items.push(item); } @@ -99,14 +103,27 @@ export class ChatTimelineProjector { id: 'empty-state', type: 'empty_state', isStreaming: false, - isFinalizing: false + isFinalizing: false, + showRetryAction: false }; items.push(item); } + ChatTimelineProjector.markLatestFailedMessageRetryable(items); return items; } + private static markLatestFailedMessageRetryable(items: ChatTimelineItem[]): void { + for (let index = items.length - 1; index >= 0; index--) { + const message = items[index].message; + if (!message) { + continue; + } + items[index].showRetryAction = (message.status || '').toLowerCase() === 'failed'; + return; + } + } + static pendingMessagesNotPersisted(pendingMessages: ChatMessage[], messages: ChatMessage[]): ChatMessage[] { return pendingMessages.filter((pending: ChatMessage) => { return !messages.some((message: ChatMessage) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets index 8b47247afb..8044588c46 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/general-chat/GeneralChatRepository.ets @@ -47,8 +47,14 @@ export class GeneralChatRepository { return cached.slice(); } const stored = await this.localStore.loadMessages(sessionId); - this.messagesBySession.set(sessionId, stored); - return stored.slice(); + const normalized = stored.map((message: ChatMessage) => GeneralChatRepository.persistableMessage(message)); + this.messagesBySession.set(sessionId, normalized); + if (normalized.some((message: ChatMessage, index: number) => { + return message.detail !== stored[index].detail; + })) { + await this.localStore.replaceMessages(sessionId, normalized); + } + return normalized.slice(); } async sendMessage( @@ -118,9 +124,10 @@ export class GeneralChatRepository { } async recordAssistantMessage(sessionId: string, message: ChatMessage): Promise { + const persistedMessage = GeneralChatRepository.persistableMessage(message); const messages = (await this.messages(sessionId)) - .filter((item: ChatMessage) => item.id !== message.id) - .concat([message]); + .filter((item: ChatMessage) => item.id !== persistedMessage.id) + .concat([persistedMessage]); this.messagesBySession.set(sessionId, messages); await this.localStore.replaceMessages(sessionId, messages); await this.bumpSession(sessionId, messages.length); @@ -329,4 +336,25 @@ export class GeneralChatRepository { images: message.images }; } + + private static persistableMessage(message: ChatMessage): ChatMessage { + if (message.role !== 'assistant' || (message.status || '').toLowerCase() !== 'failed' || + (message.detail || '').length === 0) { + return message; + } + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: '', + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools, + items: message.items, + images: message.images + }; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 38cb8ef6bd..1c6a270efd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -253,6 +253,24 @@ export default function conversationStateUnitTest() { expect(items[1].type).assertEqual('assistant_live_turn'); expect(items[1].isFinalizing).assertEqual(true); }); + + it('offers retry only for the latest unresolved failed message', 0, () => { + const interrupted = chatMessage('assistant-failed-1', 'assistant', 'Partial reply', 'failed'); + interrupted.detail = 'Retry prompt'; + const unresolved = ChatTimelineProjector.project([ + chatMessage('user-1', 'user', 'Retry prompt'), + interrupted + ], [], chatMessage('', 'assistant', ''), false); + const continued = ChatTimelineProjector.project([ + chatMessage('user-1', 'user', 'Retry prompt'), + interrupted, + chatMessage('user-2', 'user', 'Continue') + ], [], chatMessage('', 'assistant', ''), false); + + expect(unresolved[1].showRetryAction).assertTrue(); + expect(continued[1].showRetryAction).assertFalse(); + expect(continued[2].showRetryAction).assertFalse(); + }); }); describe('ChatTimelineStore', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 2138e858e5..e6b32f7f91 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -534,6 +534,21 @@ export default function transportAndGeneralChatUnitTest() { expect(messages.length).assertEqual(1); expect(messages[0].status).assertEqual('sent'); }); + + it('does not persist assistant retry detail after an interrupted turn', 0, async () => { + const localStore = new FakeGeneralChatLocalStore(); + const repository = new GeneralChatRepository(new MockGeneralChatAdapter(), localStore); + const session = await repository.createSession('中断恢复'); + const interrupted = chatMessage('assistant-failed-1', 'assistant', 'Partial reply', 'failed'); + interrupted.detail = 'Retry prompt'; + + await repository.recordAssistantMessage(session.id, interrupted); + + const messages = await repository.messages(session.id); + expect(messages[0].status).assertEqual('failed'); + expect(messages[0].detail || '').assertEqual(''); + expect((await localStore.loadMessages(session.id))[0].detail || '').assertEqual(''); + }); }); describe('GeneralChatExportFormatter', () => { From 51cb80352d2def74c8f01df2fe2d12c314b49bce Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 6 Aug 2026 10:12:21 +0800 Subject: [PATCH 020/206] feat(harmonyos): refine remote session creation and controls --- src/apps/mobile/harmonyos/AGENTS.md | 1 + .../entry/src/main/ets/model/RemoteModels.ets | 1 + .../pages/components/AppRootPresentation.ets | 14 +- .../components/ChatComposerCapabilities.ets | 15 +- .../main/ets/pages/components/ComposerBar.ets | 39 ++-- .../components/ConversationViewSettings.ets | 18 +- .../pages/components/GeneralChatHeader.ets | 24 +-- .../ets/pages/components/RemoteChatHeader.ets | 24 +-- .../components/RemoteCreateSessionView.ets | 199 ++++++++++++++---- .../ets/pages/components/RemoteHomeView.ets | 4 +- .../pages/components/RemoteSessionList.ets | 23 +- .../pages/components/SessionActionSurface.ets | 20 +- .../pages/components/SessionDetailsView.ets | 22 +- .../main/ets/pages/state/AppRootRuntime.ets | 61 +++++- .../pages/state/RemoteCreateSessionState.ets | 11 +- .../pages/state/RemoteSessionViewModel.ets | 13 +- .../ets/services/RemoteSessionController.ets | 6 +- .../ets/services/RemoteSessionManager.ets | 4 + 18 files changed, 368 insertions(+), 131 deletions(-) diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index b53cdd141c..c177c473f1 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -8,6 +8,7 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. - Conversation header controls must use the approved `remote_ref_back` and `remote_ref_more` assets. Do not replace them with a system chevron or text such as `...` / bullet characters. - Render monochrome reference assets in template mode and tint them with semantic theme colors such as `INK`. Never rely on the bitmap's original black or white pixels; the same control must remain legible in light and dark themes. - Keep paired header controls on the same fixed touch-target size and optical alignment. A responsive layout may reposition a control, but must not silently change its icon geometry or visual weight. +- Keep `SymbolGlyph` geometry separate from its touch target. When a glyph is clickable or sits in a decorated control, wrap it in `Stack({ alignContent: Alignment.Center })` (or use a centered `Button`) and give the glyph its visual size; do not stretch the glyph itself to the full 32vp/40vp/44vp target, because font metrics can make the icon look off-center. ## Responsive interaction semantics diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index ca785d95ed..ce5ad00ddd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -83,6 +83,7 @@ export interface CreateSessionOptions { agentType: string; title: string; instruction: string; + modelId?: string; } export type RemotePermissionMode = 'ask' | 'auto' | 'full_access'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index d34498c0c5..b017697362 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -14,6 +14,7 @@ import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { ComposerPresentation } from './ComposerBar'; import { ConversationViewSettings } from './ConversationViewSettings'; import { ConversationViewHost } from './ConversationViewHost'; +import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; import { RemoteCreateSessionView } from './RemoteCreateSessionView'; @@ -128,6 +129,8 @@ export class RemoteCreatePresentationActions { readonly selectDevice: (device: CloudAccountDevice) => void; readonly selectWorkspace: (path: string) => void; readonly draftChanged: (value: string) => void; + readonly voiceInput: () => void; + readonly selectModel: (modelId: string) => void; readonly send: () => void; constructor( @@ -137,6 +140,8 @@ export class RemoteCreatePresentationActions { selectDevice: (device: CloudAccountDevice) => void, selectWorkspace: (path: string) => void, draftChanged: (value: string) => void, + voiceInput: () => void, + selectModel: (modelId: string) => void, send: () => void ) { this.back = back; @@ -145,6 +150,8 @@ export class RemoteCreatePresentationActions { this.selectDevice = selectDevice; this.selectWorkspace = selectWorkspace; this.draftChanged = draftChanged; + this.voiceInput = voiceInput; + this.selectModel = selectModel; this.send = send; } } @@ -309,7 +316,7 @@ export struct AppRootPresentation { new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), + new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, @@ -452,6 +459,9 @@ export struct AppRootPresentation { RemoteCreateSessionView({ state: this.remoteCreateState, presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + isVoiceListening: this.remoteCreateState.isVoiceListening, + modelCatalog: toConversationUiModelCatalog(this.remotePageState.modelCatalog), + selectedModelId: this.remoteCreateState.selectedModelId, showSidebarRestoreButton: showSidebarRestoreButton, onRestoreSidebar: () => { this.restoreWideMasterPane(); @@ -462,6 +472,8 @@ export struct AppRootPresentation { onSelectDevice: this.actions.onRemoteCreate.selectDevice, onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), onDraftChange: this.actions.onRemoteCreate.draftChanged, + onVoiceInput: this.actions.onRemoteCreate.voiceInput, + onSelectModel: this.actions.onRemoteCreate.selectModel, onSend: this.actions.onRemoteCreate.send }) } else { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets index 372f43c008..6edfc38e4a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatComposerCapabilities.ets @@ -4,11 +4,21 @@ export class ChatComposerCapabilities { readonly surface: ChatSurface; readonly supportsAttachments: boolean; readonly requiresRemoteConnection: boolean; + readonly showAddButton: boolean; + readonly showVoiceInput: boolean; - constructor(surface: ChatSurface, supportsAttachments: boolean, requiresRemoteConnection: boolean) { + constructor( + surface: ChatSurface, + supportsAttachments: boolean, + requiresRemoteConnection: boolean, + showAddButton: boolean = true, + showVoiceInput: boolean = true + ) { this.surface = surface; this.supportsAttachments = supportsAttachments; this.requiresRemoteConnection = requiresRemoteConnection; + this.showAddButton = showAddButton; + this.showVoiceInput = showVoiceInput; } } @@ -17,3 +27,6 @@ export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities = new ChatComposerCapabilities(ChatSurface.Remote, true, true); + +export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities = + new ChatComposerCapabilities(ChatSurface.Remote, false, true, false, true); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 974055fe35..8f2d8f4ba5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -24,6 +24,7 @@ const COMPOSER_EXPANDED_INPUT_HEIGHT: number = 74; export struct ComposerBar { @Param presentation: ComposerPresentation = ComposerPresentation.Compact; @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + @Param inputId: string = 'conversation-composer-input'; @Param chatInput: string = ''; @Local inputText: string = ''; @Local inputFocused: boolean = false; @@ -212,15 +213,19 @@ export struct ComposerBar { .fontWeight(FontWeight.Medium) .fontColor(MUTED) Blank() - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(15) - .fontColor([MUTED]) - .width(32) - .height(32) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => { - this.closeModelSelector(); - }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(15) + .fontColor([MUTED]) + .width(18) + .height(18) + } + .width(32) + .height(32) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => { + this.closeModelSelector(); + }) } .width('100%') } @@ -312,7 +317,7 @@ export struct ComposerBar { this.ListeningWave() } TextArea({ placeholder: this.inputPlaceholder(), text: this.inputText }) - .id('conversation-composer-input') + .id(this.inputId) .layoutWeight(1) .height(this.isComposerExpanded() ? COMPOSER_EXPANDED_INPUT_HEIGHT : COMPOSER_INPUT_HEIGHT) .fontSize(16) @@ -369,8 +374,14 @@ export struct ComposerBar { .fontWeight(FontWeight.Medium) .fontColor([INK]) .opacity(this.canSend() ? 1 : 0.38) - } else { + } else if (this.capabilities.showVoiceInput) { this.MicrophoneGlyph() + } else { + SymbolGlyph($r('sys.symbol.arrow_up')) + .fontSize(23) + .fontWeight(FontWeight.Medium) + .fontColor([INK]) + .opacity(0.38) } } .width(40) @@ -498,7 +509,8 @@ export struct ComposerBar { } private canUseVoice(): boolean { - return ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); + return this.capabilities.showVoiceInput && + ChatComposerPolicy.canUseVoice(this.inputText, this.selectedImages.length, this.isBusy); } private hasComposedContent(): boolean { @@ -506,7 +518,8 @@ export struct ComposerBar { } private shouldShowAddButton(): boolean { - return this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General; + return this.capabilities.showAddButton && + (this.capabilities.supportsAttachments || this.capabilities.surface === ChatSurface.General); } private isComposerExpanded(): boolean { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index 0ecc0f6d0d..d965d5b08e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -116,13 +116,17 @@ export struct ConversationViewSettings { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(17) - .fontColor([MUTED]) - .width(44) - .height(44) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.onClose()) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) } .width('100%') .height(64) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index e6b78fe044..a088f54d88 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -51,17 +51,17 @@ export struct GeneralChatHeader { } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) + .width(15) + .height(23) .objectFit(ImageFit.Contain) .renderMode(ImageRenderMode.Template) .foregroundColor(INK) } - .width(48) - .height(48) + .width(44) + .height(44) .backgroundColor(CARD) .border({ width: 1, color: LINE }) - .borderRadius(24) + .borderRadius(22) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { @@ -74,7 +74,7 @@ export struct GeneralChatHeader { } }) } else { - Blank().width(48).height(48) + Blank().width(44).height(44) } } @@ -83,17 +83,17 @@ export struct GeneralChatHeader { if (this.showActions) { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.remote_ref_more')) - .width(25) - .height(8) + .width(23) + .height(7) .objectFit(ImageFit.Contain) .renderMode(ImageRenderMode.Template) .foregroundColor(INK) } - .width(48) - .height(48) + .width(44) + .height(44) .backgroundColor(CARD) .border({ width: 1, color: LINE }) - .borderRadius(24) + .borderRadius(22) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('sidebar.more')) .bindPopup(this.showActionsMenu, { @@ -114,7 +114,7 @@ export struct GeneralChatHeader { this.onOpenActions(); }) } else { - Blank().width(48).height(48) + Blank().width(44).height(44) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 3dc9561ea1..67b5b38130 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -84,41 +84,41 @@ export struct RemoteChatHeader { } else if (this.showBackButton) { Stack({ alignContent: Alignment.Center }) { Image($r('app.media.remote_ref_back')) - .width(16) - .height(25) + .width(15) + .height(23) .objectFit(ImageFit.Contain) .renderMode(ImageRenderMode.Template) .foregroundColor(INK) } - .width(48) - .height(48) + .width(44) + .height(44) .backgroundColor(CARD) .border({ width: 1, color: LINE }) - .borderRadius(24) + .borderRadius(22) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('common.back')) .onClick(() => { this.onBack(); }) } else { - Blank().width(48).height(48) + Blank().width(44).height(44) } } @Builder private ActionsControl() { Stack({ alignContent: Alignment.Center }) { - Image($r('app.media.remote_ref_more')) - .width(25) - .height(8) + Image($r('app.media.remote_ref_more')) + .width(23) + .height(7) .objectFit(ImageFit.Contain) .renderMode(ImageRenderMode.Template) .foregroundColor(INK) } - .width(48) - .height(48) + .width(44) + .height(44) .backgroundColor(CARD) - .borderRadius(24) + .borderRadius(22) .border({ width: 1, color: LINE }) .shadow({ radius: 10, color: LINE, offsetY: 3 }) .accessibilityText(RemoteI18n.t('sidebar.more')) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets index 094a022884..a11ab7ee5e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteCreateSessionView.ets @@ -4,7 +4,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, RED, SOFT, SUBTLE } from './Theme'; -import { ComposerPresentation } from './ComposerBar'; +import { ComposerBar, ComposerPresentation } from './ComposerBar'; +import { ConversationUiModelCatalog } from './ConversationUiModels'; +import { REMOTE_CREATE_COMPOSER_CAPABILITIES } from './ChatComposerCapabilities'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -12,6 +14,13 @@ export struct RemoteCreateSessionView { @Param state: RemoteCreateSessionState = new RemoteCreateSessionState(); @Param presentation: ComposerPresentation = ComposerPresentation.Create; @Param showSidebarRestoreButton: boolean = false; + @Param isVoiceListening: boolean = false; + @Param modelCatalog: ConversationUiModelCatalog = { + version: 0, + models: [], + default_models: {} + }; + @Param selectedModelId: string = ''; @Event onBack: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onToggleDeviceMenu: () => void = () => {}; @@ -20,6 +29,8 @@ export struct RemoteCreateSessionView { @Event onSelectWorkspace: (workspace?: RecentWorkspaceEntry) => void = (_workspace?: RecentWorkspaceEntry) => {}; @Event onDraftChange: (value: string) => void = (_value: string) => {}; @Event onSend: () => void = () => {}; + @Event onVoiceInput: () => void = () => {}; + @Event onSelectModel: (modelId: string) => void = (_modelId: string) => {}; @Local showSelectorSheet: boolean = false; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; @@ -35,19 +46,85 @@ export struct RemoteCreateSessionView { build() { Column() { - this.Header() - Blank() + if (this.presentation === ComposerPresentation.Floating) { + this.Header() + Blank() + .layoutWeight(1) + .onClick(() => { + this.state.closeMenu(); + this.keepComposerFocused(); + }) + } else { + this.CompactNavigationSpace() + this.ContextControls() + this.CompactComposerBar() + } + if (this.presentation === ComposerPresentation.Floating) { + this.TaskComposer() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) + } + + @Builder + CompactComposerBar() { + ComposerBar({ + presentation: ComposerPresentation.Create, + capabilities: REMOTE_CREATE_COMPOSER_CAPABILITIES, + inputId: 'remote-create-composer', + chatInput: this.state.draft, + isBusy: this.state.isSubmitting, + connectionState: 'connected', + isVoiceListening: this.isVoiceListening, + modelCatalog: this.modelCatalog, + selectedModelId: this.selectedModelId, + onSend: () => this.onSend(), + onChatInputChange: (value: string) => this.onDraftChange(value), + onVoiceInput: () => this.onVoiceInput(), + onSelectModel: (modelId: string) => this.onSelectModel(modelId) + }) + } + + @Builder + CompactNavigationSpace() { + Column() { + Row() { + Button() { + Image($r('app.media.remote_ref_back')) + .width(15) + .height(23) + .objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template) + .foregroundColor(INK) + } + .width(44) + .height(44) + .padding(0) + .type(ButtonType.Circle) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(22) + .shadow({ radius: 16, color: '#10000000', offsetY: 7 }) + .accessibilityText(RemoteI18n.t('common.back')) + .onClick(() => this.onBack()) + } + .width('100%') + .height(78) + .padding({ left: 18, top: 14 }) + .alignItems(VerticalAlign.Top) + Column() + .width('100%') .layoutWeight(1) .onClick(() => { this.state.closeMenu(); this.keepComposerFocused(); }) - this.TaskComposer() } .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - .bindSheet($$this.showSelectorSheet, this.SelectorSheet(), this.selectorSheetOptions()) + .layoutWeight(1) } @Builder @@ -93,11 +170,13 @@ export struct RemoteCreateSessionView { @Builder ContextControls() { Column({ space: 2 }) { - this.ContextRow( - 'device', - this.state.isLoadingDevices, - () => this.onToggleDeviceMenu() - ) + if (this.presentation === ComposerPresentation.Floating) { + this.ContextRow( + 'device', + this.state.isLoadingDevices, + () => this.onToggleDeviceMenu() + ) + } this.ContextRow( 'workspace', this.state.isLoadingWorkspaces, @@ -105,7 +184,12 @@ export struct RemoteCreateSessionView { ) } .width('100%') - .padding({ left: 10, right: 10, top: 8, bottom: 4 }) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 10 : 28, + right: this.presentation === ComposerPresentation.Floating ? 10 : 28, + top: this.presentation === ComposerPresentation.Floating ? 8 : 0, + bottom: 4 + }) } @Builder @@ -122,7 +206,8 @@ export struct RemoteCreateSessionView { .fontColor(loading ? MUTED : INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - SymbolGlyph($r('sys.symbol.chevron_down')) + SymbolGlyph(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces') ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) .fontSize(13) .fontColor([MUTED]) .width(22) @@ -135,8 +220,7 @@ export struct RemoteCreateSessionView { .height(48) .padding({ left: 8, right: 10 }) .borderRadius(12) - .bindPopup(this.presentation === ComposerPresentation.Floating && - this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { + .bindPopup(this.state.openMenu === (kind === 'device' ? 'devices' : 'workspaces'), { builder: () => { if (kind === 'device') { this.DeviceMenu(false) @@ -158,11 +242,7 @@ export struct RemoteCreateSessionView { }) .onClick(() => { onClick(); - if (this.presentation === ComposerPresentation.Floating) { - this.keepComposerFocused(); - } else { - this.showSelectorSheet = this.state.openMenu !== 'none'; - } + this.keepComposerFocused(); }) } @@ -193,7 +273,7 @@ export struct RemoteCreateSessionView { }, (device: CloudAccountDevice) => device.deviceId) } } - .width(asSheet ? '100%' : 360) + .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) .borderRadius(asSheet ? 0 : 16) @@ -260,11 +340,13 @@ export struct RemoteCreateSessionView { } } .width('100%') - .constraintSize({ maxHeight: 190 }) + .constraintSize({ + maxHeight: this.presentation === ComposerPresentation.Floating ? 190 : 92 + }) .scrollBar(BarState.Off) } } - .width(asSheet ? '100%' : 360) + .width(asSheet ? '100%' : (this.presentation === ComposerPresentation.Floating ? 360 : 340)) .padding({ top: 8, bottom: 8 }) .backgroundColor(CARD) .borderRadius(asSheet ? 0 : 16) @@ -336,13 +418,17 @@ export struct RemoteCreateSessionView { .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(INK) - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(16) - .fontColor([MUTED]) - .width(44) - .height(44) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.closeSelectorSheet()) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.closeSelectorSheet()) } .width('100%') .height(52) @@ -362,10 +448,12 @@ export struct RemoteCreateSessionView { @Builder TaskComposer() { Column({ space: 6 }) { - this.ContextControls() - Divider() - .color(LINE) - .margin({ left: 18, right: 18 }) + if (this.presentation === ComposerPresentation.Floating) { + this.ContextControls() + Divider() + .color(LINE) + .margin({ left: 18, right: 18 }) + } if (this.state.errorText.length > 0) { Text(this.state.errorText) .width('100%') @@ -407,25 +495,44 @@ export struct RemoteCreateSessionView { .onClick(() => this.onSend()) } .width('100%') - .height(72) + .height(this.presentation === ComposerPresentation.Floating ? 72 : 66) .padding({ left: 12, right: 7, top: 5, bottom: 5 }) + .backgroundColor(this.presentation === ComposerPresentation.Floating ? '#00000000' : CARD) + .borderRadius(this.presentation === ComposerPresentation.Floating ? 0 : 25) + .border({ + width: this.presentation === ComposerPresentation.Floating ? 0 : 1, + color: this.presentation === ComposerPresentation.Floating ? '#00000000' : SOFT + }) + .shadow({ + radius: this.presentation === ComposerPresentation.Floating ? 0 : 22, + color: this.presentation === ComposerPresentation.Floating ? '#00000000' : '#18000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 0 : 7 + }) } .width('100%') .constraintSize({ maxWidth: this.presentation === ComposerPresentation.Floating ? 760 : 10000 }) .alignSelf(ItemAlign.Center) - .padding({ top: 4, bottom: 4 }) - .backgroundColor(CARD) - .borderRadius(18) - .border({ width: 1, color: SOFT }) + .padding({ + left: this.presentation === ComposerPresentation.Floating ? 0 : 16, + right: this.presentation === ComposerPresentation.Floating ? 0 : 16, + top: this.presentation === ComposerPresentation.Floating ? 4 : 0, + bottom: this.presentation === ComposerPresentation.Floating ? 4 : 14 + }) + .backgroundColor(this.presentation === ComposerPresentation.Floating ? CARD : '#00000000') + .borderRadius(this.presentation === ComposerPresentation.Floating ? 18 : 0) + .border({ + width: this.presentation === ComposerPresentation.Floating ? 1 : 0, + color: this.presentation === ComposerPresentation.Floating ? SOFT : '#00000000' + }) .shadow({ - radius: this.presentation === ComposerPresentation.Floating ? 20 : 12, - color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#0D000000', - offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 2 + radius: this.presentation === ComposerPresentation.Floating ? 20 : 0, + color: this.presentation === ComposerPresentation.Floating ? '#18000000' : '#00000000', + offsetY: this.presentation === ComposerPresentation.Floating ? 6 : 0 }) .margin({ - left: this.presentation === ComposerPresentation.Floating ? 24 : 16, - right: this.presentation === ComposerPresentation.Floating ? 24 : 16, - bottom: this.presentation === ComposerPresentation.Floating ? 24 : 14 + left: this.presentation === ComposerPresentation.Floating ? 24 : 0, + right: this.presentation === ComposerPresentation.Floating ? 24 : 0, + bottom: this.presentation === ComposerPresentation.Floating ? 24 : 0 }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets index 5059039e4d..eb8b9dabb3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets @@ -155,8 +155,8 @@ export struct RemoteHomeView { Column() { this.RemoteActionsLayer() } - .width(330) - .height(292) + .width(280) + .height(252) .margin({ top: 4, right: 4 }) .transition(TransitionEffect.translate({ x: 18, y: -12 }) .combine(TransitionEffect.opacity(0)) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index b468b95803..d1d6b2d8c5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -349,15 +349,20 @@ export struct RemoteSessionList { this.chatsCollapsed = !this.chatsCollapsed; }) Blank() - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(18) - .fontColor([MUTED]) - .width(22) - .height(22) - .opacity(0.52) - .onClick(() => { - this.onCreateAssistantSession(); - }) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(18) + .fontColor([MUTED]) + .width(22) + .height(22) + .opacity(0.52) + } + .width(40) + .height(40) + .accessibilityText(RemoteI18n.t('remote.newChat')) + .onClick(() => { + this.onCreateAssistantSession(); + }) } .width('100%') .height(44) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets index 2fad4dff86..a8eb8b377d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionActionSurface.ets @@ -51,13 +51,17 @@ export struct SessionActionSurface { .layoutWeight(1) .alignItems(HorizontalAlign.Start) - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(16) - .fontColor([MUTED]) - .width(40) - .height(40) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.onClose()) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(16) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(40) + .height(40) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) } .width('100%') .height(52) @@ -125,7 +129,7 @@ export struct SessionActionSurface { .height(46) .padding({ left: 10, right: 10 }) .borderRadius(8) - .backgroundColor(destructive ? SOFT : '#00000000') + .backgroundColor('#00000000') .onClick(action) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets index 008d1a5143..e0c07c0d58 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SessionDetailsView.ets @@ -35,15 +35,19 @@ export struct SessionDetailsView { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - SymbolGlyph($r('sys.symbol.xmark')) - .fontSize(17) - .fontColor([MUTED]) - .width(44) - .height(44) - .backgroundColor(SOFT) - .borderRadius(22) - .accessibilityText(RemoteI18n.t('common.close')) - .onClick(() => this.onClose()) + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.xmark')) + .fontSize(17) + .fontColor([MUTED]) + .width(20) + .height(20) + } + .width(44) + .height(44) + .backgroundColor(SOFT) + .borderRadius(22) + .accessibilityText(RemoteI18n.t('common.close')) + .onClick(() => this.onClose()) } .width('100%') .padding({ left: 20, right: 16, top: 18, bottom: 16 }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets index c4f46a32bb..92c99512df 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets @@ -669,6 +669,8 @@ export class AppRootRuntime { (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, (path: string): void => this.selectRemoteCreateWorkspace(path), (value: string): void => this.remoteCreateState.setDraft(value), + async (): Promise => { await this.toggleVoiceInput(); }, + (modelId: string): void => this.selectRemoteCreateModel(modelId), (): void => { this.submitRemoteCreateSession(); } ), new SidebarPresentationActions( @@ -800,6 +802,9 @@ export class AppRootRuntime { } visibleChatInput(): string { + if (this.currentRoute() === AppRoute.RemoteCreate) { + return this.remoteCreateState.draft; + } return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); } @@ -808,10 +813,17 @@ export class AppRootRuntime { } visibleVoiceListening(): boolean { + if (this.currentRoute() === AppRoute.RemoteCreate) { + return this.remoteCreateState.isVoiceListening; + } return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); } setChatInputForRoute(route: AppRoute, value: string): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreateState.setDraft(value); + return; + } AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); } @@ -832,6 +844,10 @@ export class AppRootRuntime { } setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreateState.isVoiceListening = isVoiceListening; + return; + } AppRootRouteState.setVoiceListening( route, isVoiceListening, @@ -846,6 +862,15 @@ export class AppRootRuntime { } voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { + if (route === AppRoute.RemoteCreate) { + return { + routeId: `${route}`, + isListening: this.remoteCreateState.isVoiceListening, + isBusy: this.remoteCreateState.isSubmitting, + inputText: this.remoteCreateState.draft, + selectedImageCount: 0 + }; + } return AppRootRouteState.snapshot( route, this.visibleChatBusy(), @@ -1815,7 +1840,7 @@ export class AppRootRuntime { } const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName); + this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); if (deviceId.length > 0) { this.remoteCreateState.setDevices([{ deviceId, @@ -1826,10 +1851,12 @@ export class AppRootRuntime { this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); this.pushRoute(AppRoute.RemoteCreate); this.loadRemoteCreateChoices(); + this.loadRemoteCreateModelCatalog(); } closeRemoteCreateSession(): void { this.remoteCreateWorkspaceLoadVersion += 1; + this.stopVoiceInput(false); this.remoteCreateState.closeMenu(); this.popRoute(AppRoute.RemoteHome); } @@ -1841,6 +1868,23 @@ export class AppRootRuntime { ]); } + async loadRemoteCreateModelCatalog(): Promise { + if (this.remotePageState.modelCatalog.models.length > 0) { + return; + } + try { + const catalog = await this.sessionManager.getModelCatalog(); + const selectedModelId = RemoteUiState.selectedModelIdForCatalog( + catalog, + this.remotePageState.selectedModelId + ); + this.remotePageState.setModelCatalog(catalog, selectedModelId); + this.remoteCreateState.setSelectedModelId(selectedModelId); + } catch (_err) { + // Model selection remains hidden when the remote does not expose a catalog. + } + } + async loadRemoteCreateDevices(): Promise { this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; try { @@ -1933,6 +1977,10 @@ export class AppRootRuntime { this.remoteCreateState.selectWorkspace(workspace); } + selectRemoteCreateModel(modelId: string): void { + this.remoteCreateState.setSelectedModelId(modelId); + } + async submitRemoteCreateSession(): Promise { const instruction = this.remoteCreateState.draft.trim(); if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { @@ -1953,10 +2001,17 @@ export class AppRootRuntime { context.workspacePath, this.workspacePath, instruction, - context.agentType + context.agentType, + undefined, + this.remoteCreateState.selectedModelId ); } else { - await this.remoteSessionViewModel.createSession(context.agentType, instruction); + await this.remoteSessionViewModel.createSession( + context.agentType, + instruction, + undefined, + this.remoteCreateState.selectedModelId + ); } if (this.isRoute(AppRoute.RemoteCreate)) { this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index a45b1f0b05..27e2d77f75 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -26,9 +26,11 @@ export class RemoteCreateSessionState { @Trace isLoadingDevices: boolean = false; @Trace isLoadingWorkspaces: boolean = false; @Trace isSubmitting: boolean = false; + @Trace isVoiceListening: boolean = false; + @Trace selectedModelId: string = ''; @Trace errorText: string = ''; - prepare(deviceId: string, deviceName: string): void { + prepare(deviceId: string, deviceName: string, selectedModelId: string = ''): void { this.draft = ''; this.devices = []; this.workspaces = []; @@ -40,6 +42,8 @@ export class RemoteCreateSessionState { this.isLoadingDevices = false; this.isLoadingWorkspaces = false; this.isSubmitting = false; + this.isVoiceListening = false; + this.selectedModelId = selectedModelId; this.errorText = ''; } @@ -48,6 +52,11 @@ export class RemoteCreateSessionState { this.errorText = ''; } + setSelectedModelId(modelId: string): void { + this.selectedModelId = modelId; + this.errorText = ''; + } + setDevices(devices: CloudAccountDevice[]): void { this.devices = devices.slice(); const selected = devices.find((device: CloudAccountDevice): boolean => diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets index 4c9ac5df2d..fbedef6d7d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets @@ -112,7 +112,8 @@ export class RemoteSessionViewModel { async createSession( agentType: string, instruction: string = '', - onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, + modelId: string = '' ): Promise { await this.sessions.create( agentType, @@ -129,7 +130,8 @@ export class RemoteSessionViewModel { await this.hooks.onLoadActiveMessages(); this.hooks.onStartPolling(); }, - instruction + instruction, + modelId ); } @@ -138,15 +140,16 @@ export class RemoteSessionViewModel { currentPath: string, instruction: string = '', agentType: string = 'code', - onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat + onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat, + modelId: string = '' ): Promise { if (path.length > 0 && path !== currentPath) { await this.hooks.onSelectWorkspace(path); - await this.createSession(agentType, instruction, onRouteChat); + await this.createSession(agentType, instruction, onRouteChat, modelId); return; } if (path.length === 0 || path === currentPath) { - await this.createSession(agentType, instruction, onRouteChat); + await this.createSession(agentType, instruction, onRouteChat, modelId); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index a1fd0f7c64..8bbcd94eff 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -130,7 +130,8 @@ export class RemoteSessionController { isBusy: boolean, remoteAvailable: boolean, onCreated: (session: SessionSummary) => Promise, - instruction: string = '' + instruction: string = '', + modelId: string = '' ): Promise { if (isBusy || !remoteAvailable) { return; @@ -141,7 +142,8 @@ export class RemoteSessionController { const session = await this.client.createSession({ agentType, title: '', - instruction + instruction, + modelId }); this.callbacks.onActiveSession(session); this.callbacks.onStatusText(RemoteI18n.t('status.sessionCreated')); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 774addea4c..ac6a07d757 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -182,6 +182,10 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile const command = RemoteCommandFactory.createSession(options, workspacePath); const response = await this.send(command); const sessionId = response.session_id || response.id || ''; + const modelId = options.modelId?.trim() || ''; + if (modelId.length > 0) { + await this.setSessionModel(sessionId, modelId); + } const normalizedAgentType = options.agentType.toLowerCase(); const fallbackTitle = normalizedAgentType === 'claw' || normalizedAgentType === 'assistant' || normalizedAgentType === 'chat' ? 'Assistant Session' : normalizedAgentType === 'cowork' ? 'Cowork Session' : 'Code Session'; From 38c289c713fde5ff4dc36b7621bec3538e9607eb Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 6 Aug 2026 19:37:37 +0800 Subject: [PATCH 021/206] refactor(harmonyos): unify Local and Remote conversation shells --- .../entry/src/main/ets/i18n/RemoteI18n.ets | 3 + .../entry/src/main/ets/pages/AppRoot.ets | 1 - .../pages/components/AppRootPresentation.ets | 606 ++++++++++-------- .../main/ets/pages/components/AppSidebar.ets | 149 +++-- .../pages/components/RemoteActionsSheet.ets | 312 --------- .../ets/pages/components/RemoteBottomBar.ets | 69 -- .../ets/pages/components/RemoteHeader.ets | 119 ---- .../ets/pages/components/RemoteHomeView.ets | 370 ----------- .../main/ets/pages/state/AppRootRuntime.ets | 32 + .../main/ets/pages/state/AppShellState.ets | 8 + .../ets/pages/state/AppShellViewModel.ets | 21 +- 11 files changed, 496 insertions(+), 1194 deletions(-) delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index f55c73b6ab..1fce0571d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -163,6 +163,9 @@ const ZH_CN_MESSAGES: [string, string][] = [ ['remote.searchChats', '搜索聊天记录'], ['remote.emptyTitle', '还没有远程对话'], ['remote.emptyText', '新建聊天后,可以从手机继续处理桌面端任务。'], + ['remote.pickSession', '选择一个会话'], + ['remote.pickSessionText', '从侧边栏打开会话,或新建一个。'], + ['remote.startSession', '新建会话'], ['remote.connectTitle', '连接桌面端'], ['remote.connectText', '扫描桌面端显示的二维码,开始远程处理任务。'], ['remote.actions', '远程设置'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index cb27247172..4a918697db 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -39,7 +39,6 @@ struct AppRoot { generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), - currentRoute: this.runtime.currentRoute(), actions: this.runtime.presentationActions }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index b017697362..c38cd9de27 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -10,22 +10,21 @@ import { AppShell } from './AppShell'; import { AppSidebar } from './AppSidebar'; import { ConnectView } from './ConnectView'; import { ConversationIntent } from './ConversationIntent'; -import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { ComposerPresentation } from './ComposerBar'; import { ConversationViewSettings } from './ConversationViewSettings'; import { ConversationViewHost } from './ConversationViewHost'; import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; +import { GeneralChatHeader } from './GeneralChatHeader'; import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; import { RemoteCreateSessionView } from './RemoteCreateSessionView'; -import { RemoteHomeView } from './RemoteHomeView'; import { RemoteSessionList } from './RemoteSessionList'; import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SessionActionPresentation } from './SessionActionSurface'; import { SettingsSheet } from './SettingsSheet'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; -import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +import { AppRoute, AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; import { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, @@ -65,6 +64,7 @@ export class AppRootPresentationActions { readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; readonly onCloseSidebar: () => void; readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactConversationSource: (source: ConversationSource) => void; readonly onCompactLayoutEntered: () => void; readonly onRemoteHome: RemoteHomePresentationActions; readonly onRemoteCreate: RemoteCreatePresentationActions; @@ -79,6 +79,7 @@ export class AppRootPresentationActions { onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, onCloseSidebar: () => void, onWideConversationSource: (source: ConversationSource) => void, + onCompactConversationSource: (source: ConversationSource) => void, onCompactLayoutEntered: () => void, onRemoteHome: RemoteHomePresentationActions, onRemoteCreate: RemoteCreatePresentationActions, @@ -92,6 +93,7 @@ export class AppRootPresentationActions { this.onConversationIntent = onConversationIntent; this.onCloseSidebar = onCloseSidebar; this.onWideConversationSource = onWideConversationSource; + this.onCompactConversationSource = onCompactConversationSource; this.onCompactLayoutEntered = onCompactLayoutEntered; this.onRemoteHome = onRemoteHome; this.onRemoteCreate = onRemoteCreate; @@ -276,7 +278,6 @@ export struct AppRootPresentation { @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); @Param filePreviewState: FilePreviewState = new FilePreviewState(); @Param deviceId: string = ''; - @Param currentRoute: AppRoute = AppRoute.ChatHome; @Local viewportWidth: number = 0; @Local wideLayoutMatched: boolean = false; @Local foldStatus: display.FoldStatus = safeFoldStatus(); @@ -312,7 +313,7 @@ export struct AppRootPresentation { this.refreshWideGeometry(); }; @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, () => {}, + () => false, () => {}, () => {}, () => {}, () => {}, () => {}, new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), @@ -403,58 +404,7 @@ export struct AppRootPresentation { ) { Column() { if (route === AppRoute.RemoteHome) { - RemoteHomeView({ pageState: this.remotePageState, isBusy: this.remotePageState.isBusy, - selectedSessionId: this.remotePageState.activeSession.sessionId, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - onOpenSidebar: this.actions.onRemoteHome.openSidebar, - onConnectWorkspace: this.actions.onRemoteHome.connectWorkspace, - onAddConnection: this.actions.onRemoteHome.addConnection, - onOpenRemoteSettings: this.actions.onRemoteHome.openSettings, - onRefresh: this.actions.onRemoteHome.refresh, - onShowWorkspaces: this.actions.onRemoteHome.showWorkspaces, - onShowAssistants: this.actions.onRemoteHome.showAssistants, - onSelectWorkspace: this.actions.onRemoteHome.selectWorkspace, - onSelectAssistant: this.actions.onRemoteHome.selectAssistant, - onCancelWorkspacePicker: this.actions.onRemoteHome.cancelWorkspace, - onCancelAssistantPicker: this.actions.onRemoteHome.cancelAssistant, - onSessionQueryChange: this.actions.onRemoteHome.queryChanged, - onSearchSessions: this.actions.onRemoteHome.search, - onLoadMoreSessions: this.actions.onRemoteHome.loadMore, - onReconnect: this.actions.onRemoteHome.reconnect, - onDisconnect: this.actions.onRemoteHome.disconnect, - onClearPairing: this.actions.onRemoteHome.clearPairing, - onCreate: this.actions.onRemoteHome.create, - onCreateAssistantSession: this.actions.onRemoteHome.createAssistant, - onCreateInWorkspace: this.actions.onRemoteHome.createInWorkspace, - onOpenSession: this.actions.onRemoteHome.openSession, - onDeleteSession: this.actions.onRemoteHome.deleteSession, - onSortModeChange: (mode: string) => { - this.remoteWideSortMode = mode; - }, - onWorkspaceFilterChange: (value: string) => { - this.remoteWorkspaceFilter = value; - }, - onAgentFilterChange: (value: string) => { - this.remoteAgentFilter = value; - }, - onStatusFilterChange: (value: string) => { - this.remoteStatusFilter = value; - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.showRemoteWorkspaceMetadata = value; - }, - onUpdatedMetadataChange: (value: boolean) => { - this.showRemoteUpdatedMetadata = value; - }, - onStatusMetadataChange: (value: boolean) => { - this.showRemoteStatusMetadata = value; - } }) + this.CompactRemoteHomeContent() } else if (route === AppRoute.RemoteCreate) { RemoteCreateSessionView({ state: this.remoteCreateState, @@ -502,45 +452,7 @@ export struct AppRootPresentation { WideGeneralChatContent(route: AppRoute) { Row() { if (!this.wideMasterPaneCollapsed) { - Column() { - Column() { - AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: 'chat', - showConversationSourceSwitcher: true, - showCollapseButton: true, - conversationSource: ConversationSource.General, - onClose: this.actions.onSidebar.close, - onNewChat: this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onCollapse: () => { - this.collapseWideMasterPane(); - }, - onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession }) - } - .width('100%') - .height('100%') - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) - } - .width(this.wideMasterPaneWidth) - .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) - + this.WideMasterPane(ConversationSource.General, false) this.WideMasterDetailGap() } this.WideConversationDetail(route, false) @@ -554,7 +466,7 @@ export struct AppRootPresentation { WideRemoteHomeContent() { Row() { if (!this.wideMasterPaneCollapsed) { - this.RemoteMasterPane(false) + this.WideMasterPane(ConversationSource.Remote, false) this.WideMasterDetailGap() } @@ -574,7 +486,7 @@ export struct AppRootPresentation { WideRemoteCreateContent() { Row() { if (!this.wideMasterPaneCollapsed) { - this.RemoteMasterPane(false) + this.WideMasterPane(ConversationSource.Remote, false) this.WideMasterDetailGap() } this.WideConversationDetail(AppRoute.RemoteCreate, false) @@ -584,23 +496,88 @@ export struct AppRootPresentation { .backgroundColor(PAGE_BG) } + /** + * The single wide master pane shell. Local and Remote differ only in the + * session content they hand to the shared sidebar, so the header, source + * switcher, content origin and footer never move when the source changes. + */ @Builder - RemoteMasterPane( - showSelectedSession: boolean, - paneWidth: number = this.wideMasterPaneWidth - ) { + WideMasterPane(source: ConversationSource, showSelectedSession: boolean) { Column() { - Column({ space: 12 }) { - this.RemoteWidePaneHeader() - ConversationSourceSwitcher({ - activeSource: ConversationSource.Remote, - onSelectSource: this.actions.onWideConversationSource + Column() { + AppSidebar({ + sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: source === ConversationSource.Remote ? '' : + this.generalPageState.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + showViewSettingsButton: source === ConversationSource.Remote, + showCustomContent: source === ConversationSource.Remote, + conversationSource: source, + contentSlot: () => { + this.RemoteMasterContent(showSelectedSession); + }, + onClose: this.actions.onSidebar.close, + onNewChat: source === ConversationSource.Remote ? + this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: () => { + this.collapseWideMasterPane(); + }, + onOpenViewSettings: () => { + this.showRemoteViewSettings = true; + }, + onSearchQueryChange: (query: string) => { + if (source === ConversationSource.Remote) { + this.actions.onRemoteHome.queryChanged(query); + } + }, + onOpenSettings: source === ConversationSource.Remote ? + this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession }) - if (this.isRemoteWideInitialLoading()) { - RemoteSessionLoadingView() - this.RemoteWideSearchBar() - } else if (this.canShowRemoteWideList()) { - RemoteSessionList({ + } + .width('100%') + .height('100%') + .backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18) + .clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(this.wideMasterPaneCurrentWidth()) + .height('100%') + .padding({ left: 10, right: 6, top: 10, bottom: 10 }) + .backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }) + .combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : + TransitionEffect.opacity(1)) + } + + /** + * Remote session content for the shared sidebar shell. The wide master pane + * opens sessions in place next to the list; the compact drawer has to close + * itself and navigate, so every entry point is routed through a compact flag + * instead of a second copy of the list. + */ + @Builder + RemoteMasterContent(showSelectedSession: boolean, compact: boolean = false) { + Column() { + this.RemoteStatusRow() + if (this.isRemoteInitialLoading()) { + RemoteSessionLoadingView() + } else if (this.canShowRemoteSessionList()) { + RemoteSessionList({ sessions: this.remotePageState.visibleSessions(), query: this.remotePageState.sessionQuery, sortMode: this.remoteWideSortMode, @@ -619,19 +596,19 @@ export struct AppRootPresentation { isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', onCreate: () => { - this.actions.onRemoteHome.createInPlace('code'); + this.createRemoteSession('code', compact); }, onCreateAssistantSession: () => { - this.actions.onRemoteHome.createAssistant(); + this.createRemoteAssistantSession(compact); }, onCreateInWorkspace: (path: string, agentType: string) => { - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + this.createRemoteSessionInWorkspace(path, agentType, compact); }, onSelectWorkspace: (path: string) => { this.actions.onRemoteHome.selectWorkspace(path); }, onOpenSession: (session: RemoteSession) => { - this.actions.onRemoteHome.openSessionInPlace(session); + this.openRemoteSession(session, compact); }, onDeleteSession: (session: RemoteSession) => { this.actions.onRemoteHome.deleteSession(session); @@ -639,86 +616,32 @@ export struct AppRootPresentation { onLoadMore: () => { this.actions.onRemoteHome.loadMore(); } - }) - this.RemoteWideSearchBar() - } else { - this.RemoteWideDisconnected() - } + }) + } else { + this.RemoteDisconnectedState() } - .width('100%') - .height('100%') - .padding({ left: 20, right: 16, top: 10, bottom: 8 }) - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) } - .width(paneWidth) + .width('100%') .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) } + /** Connection status lives in the remote content, not in the shared header. */ @Builder - RemoteWidePaneHeader() { - Row({ space: 10 }) { - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.title')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 6 }) { - this.RemoteWideStatusIndicator() - Text(this.remoteWideStatusText()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Center) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - Row({ space: 6 }) { - Stack({ alignContent: Alignment.Center }) { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) - } - .width(38) - .height(38) - .backgroundColor(CARD) - .borderRadius(19) - .border({ width: 1, color: LINE }) - .accessibilityText(RemoteI18n.t('remote.actions')) - .onClick(() => { - this.showRemoteViewSettings = true; - }) - - SidebarToggleButton({ - controlSize: 38, - onToggle: () => { - this.collapseWideMasterPane(); - } - }) - } + RemoteStatusRow() { + Row({ space: 6 }) { + this.RemoteStatusIndicator() + Text(this.remoteStatusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) } .width('100%') - .height(46) + .margin({ top: 16, bottom: 6 }) .alignItems(VerticalAlign.Center) } @@ -766,8 +689,8 @@ export struct AppRootPresentation { } @Builder - RemoteWideStatusIndicator() { - if (this.isRemoteWideInitialLoading()) { + RemoteStatusIndicator() { + if (this.isRemoteInitialLoading()) { LoadingProgress() .width(14) .height(14) @@ -778,68 +701,13 @@ export struct AppRootPresentation { } .width(7) .height(7) - .backgroundColor(this.remoteWideStatusColor()) + .backgroundColor(this.remoteStatusColor()) .borderRadius(4) } } @Builder - RemoteWideSearchBar() { - Row({ space: 8 }) { - Row({ space: 7 }) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(16) - .fontColor([MUTED]) - .width(18) - .height(18) - .opacity(0.58) - TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.remotePageState.sessionQuery }) - .height(38) - .fontSize(14) - .fontColor(INK) - .placeholderColor(MUTED) - .backgroundColor('#00000000') - .padding({ left: 0, right: 0 }) - .layoutWeight(1) - .onChange((value: string) => { - this.actions.onRemoteHome.queryChanged(value); - }) - .onSubmit(() => { - this.actions.onRemoteHome.search(); - }) - } - .height(42) - .layoutWeight(1) - .padding({ left: 12, right: 10 }) - .backgroundColor(CARD) - .borderRadius(21) - .border({ width: 1, color: LINE }) - - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(20) - .fontColor([PRIMARY_ACTION_TEXT]) - .width(22) - .height(22) - } - .width(42) - .height(42) - .backgroundColor(PRIMARY_ACTION) - .borderRadius(21) - .opacity(this.isRemoteWideActionBusy() ? 0.45 : 1) - .onClick(() => { - if (!this.isRemoteWideActionBusy()) { - this.actions.onRemoteHome.createAssistant(); - } - }) - } - .width('100%') - .height(46) - .alignItems(VerticalAlign.Center) - } - - @Builder - RemoteWideDisconnected() { + RemoteDisconnectedState() { Column({ space: 12 }) { Stack({ alignContent: Alignment.Center }) { SymbolGlyph($r('sys.symbol.desktop')) @@ -884,10 +752,7 @@ export struct AppRootPresentation { WideRemoteChatContent() { if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { Row() { - this.RemoteMasterPane( - true, - this.filePreviewLayout().masterPaneWidth - ) + this.WideMasterPane(ConversationSource.Remote, true) this.WidePaneGap(this.filePreviewLayout().masterConversationGap) this.WideConversationDetail( AppRoute.RemoteChat, @@ -903,7 +768,7 @@ export struct AppRootPresentation { } else { Row() { if (!this.wideMasterPaneCollapsed) { - this.RemoteMasterPane(true) + this.WideMasterPane(ConversationSource.Remote, true) this.WideMasterDetailGap() } this.WideConversationDetail(AppRoute.RemoteChat, false) @@ -1031,6 +896,74 @@ export struct AppRootPresentation { } } + /** + * Compact Remote landing surface. The session list lives in the shared drawer + * now, so this route only carries connection state and the way back into the + * drawer — the same shape the Local composer route has. + */ + @Builder + CompactRemoteHomeContent() { + Column() { + GeneralChatHeader({ + title: RemoteI18n.t('remote.title'), + showSidebarButton: true, + onOpenSidebar: this.actions.onRemoteHome.openSidebar + }) + if (this.canShowRemoteSessionList()) { + this.CompactRemoteEmptyState() + } else { + this.RemoteDisconnectedState() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + CompactRemoteEmptyState() { + Column({ space: 10 }) { + if (this.isRemoteInitialLoading()) { + LoadingProgress() + .width(28) + .height(28) + .color(MUTED) + .margin({ bottom: 8 }) + } + Text(this.compactRemoteEmptyTitle()) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .textAlign(TextAlign.Center) + Text(this.compactRemoteEmptyText()) + .fontSize(14) + .lineHeight(21) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + Text(RemoteI18n.t('remote.startSession')) + .width(148) + .height(46) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center) + .borderRadius(23) + .margin({ top: 12 }) + .onClick(() => { + this.actions.onRemoteHome.createAssistant(); + }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .padding({ left: 24, right: 24, bottom: 56 }) + } + @Builder RemoteFlowPlaceholder() { Column() { @@ -1067,7 +1000,7 @@ export struct AppRootPresentation { .border({ width: { bottom: 1 }, color: LINE }) Column({ space: 8 }) { - if (this.isRemoteWideInitialLoading()) { + if (this.isRemoteInitialLoading()) { LoadingProgress() .width(28) .height(28) @@ -1078,7 +1011,7 @@ export struct AppRootPresentation { .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor(INK) - Text(this.remoteWideStatusText()) + Text(this.remoteStatusText()) .fontSize(14) .fontColor(MUTED) .maxLines(2) @@ -1099,6 +1032,16 @@ export struct AppRootPresentation { return this.largeScreenLayout; } + /** + * Read inside the master pane builder rather than passed in: a @Builder only + * re-renders on parameters passed by reference, so a width handed over as a + * value would freeze at whatever the pane measured on its first render. + */ + private wideMasterPaneCurrentWidth(): number { + return this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane ? + this.filePreviewLayout().masterPaneWidth : this.wideMasterPaneWidth; + } + private collapseWideMasterPane(): void { if (!this.isWideLayout() || this.filePreviewState.visible) { return; @@ -1259,33 +1202,89 @@ export struct AppRootPresentation { } } - private canShowRemoteWideList(): boolean { + /** + * Session entry points shared by the wide master pane and the compact drawer. + * The wide pane keeps the list on screen and swaps the detail pane; the + * compact drawer has to dismiss itself first and then navigate. + */ + private openRemoteSession(session: RemoteSession, compact: boolean): void { + if (compact) { + this.actions.onSidebar.openSession(session); + return; + } + this.actions.onRemoteHome.openSessionInPlace(session); + } + + private createRemoteSession(agentType: string, compact: boolean): void { + if (compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.create(agentType); + return; + } + this.actions.onRemoteHome.createInPlace(agentType); + } + + private createRemoteSessionInWorkspace(path: string, agentType: string, compact: boolean): void { + if (compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createInWorkspace(path, agentType); + return; + } + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + } + + private createRemoteAssistantSession(compact: boolean): void { + if (compact) { + this.actions.onSidebar.close(); + } + this.actions.onRemoteHome.createAssistant(); + } + + private compactSidebarSource(): ConversationSource { + return AppRouteContract.conversationSource(this.shellState.activeRoute); + } + + /** The compact drawer's new-chat and settings entries follow the active source. */ + private compactSidebarNewChat(source: ConversationSource): void { + if (source === ConversationSource.Remote) { + this.createRemoteAssistantSession(true); + return; + } + this.actions.onSidebar.newChat(); + } + + private compactSidebarSettings(source: ConversationSource): void { + if (source === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.openSettings(); + return; + } + this.actions.onSidebar.settings(); + } + + private canShowRemoteSessionList(): boolean { return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; } - private isRemoteWideInitialLoading(): boolean { - return this.remotePageState.isLoadingHome || this.isRemoteWideConnecting(); + private isRemoteInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isRemoteConnecting(); } - private isRemoteWideConnecting(): boolean { + private isRemoteConnecting(): boolean { return this.remotePageState.connectionState === 'parsing' || this.remotePageState.connectionState === 'pairing' || this.remotePageState.connectionState === 'reconnecting'; } - private isRemoteWideActionBusy(): boolean { - return this.remotePageState.isBusy || this.isRemoteWideInitialLoading(); - } - - private remoteWideStatusText(): string { + private remoteStatusText(): string { if (this.remotePageState.statusText.length > 0) { return this.remotePageState.statusText; } return this.remoteDesktopName(); } - private remoteWideStatusColor(): ResourceColor { + private remoteStatusColor(): ResourceColor { if (this.remotePageState.connectionState === 'connected') { return GREEN; } @@ -1300,14 +1299,39 @@ export struct AppRootPresentation { RemoteI18n.t('remote.settings.noDesktop'); } + private compactRemoteEmptyTitle(): string { + if (this.isRemoteInitialLoading()) { + return RemoteI18n.t('common.loading'); + } + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); + } + + private compactRemoteEmptyText(): string { + if (this.isRemoteInitialLoading()) { + return this.remoteStatusText(); + } + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); + } + private remoteFlowPlaceholderTitle(): string { - if (this.isRemoteWideInitialLoading()) { + if (this.isRemoteInitialLoading()) { return RemoteI18n.t('common.loading'); } return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); } private remoteViewSettingsSheetOptions(): SheetOptions { + if (!this.isWideLayout()) { + return { + height: 520, + backgroundColor: '#00000000', + maskColor: '#44000000', + showClose: false, + dragBar: true + }; + } return { height: 520, width: 560, @@ -1319,18 +1343,54 @@ export struct AppRootPresentation { }; } + /** + * The compact drawer runs the same sidebar shell as the wide master pane, so + * Local and Remote are two sources inside one session container instead of a + * drawer and a separate destination page. The drawer outlives every route + * change, and a @Builder does not re-render on value parameters, so the source + * is read from the current route on each render instead of being passed in. + */ @Builder SidebarContent() { - AppSidebar({ sessions: this.generalPageState.recentSessions(), pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.currentRoute === AppRoute.ChatHome || this.currentRoute === AppRoute.GeneralChat ? - this.generalPageState.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, + AppSidebar({ + sessions: this.compactSidebarSource() === ConversationSource.Remote ? + [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.compactSidebarSource() === ConversationSource.Remote ? '' : + (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? + this.generalPageState.activeSession.sessionId : ''), + connectionState: this.remotePageState.connectionState, accountUserId: this.remotePageState.accountUserId, - activeSection: this.currentRoute === AppRoute.RemoteHome || this.currentRoute === AppRoute.RemoteChat ? 'remote' : 'chat', + activeSection: this.compactSidebarSource() === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showViewSettingsButton: this.compactSidebarSource() === ConversationSource.Remote, + showCustomContent: this.compactSidebarSource() === ConversationSource.Remote, + conversationSource: this.compactSidebarSource(), + contentSlot: () => { + this.RemoteMasterContent(true, true); + }, onClose: this.actions.onSidebar.close, - onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, - onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, + onNewChat: () => { + this.compactSidebarNewChat(this.compactSidebarSource()); + }, + onEnterCode: this.actions.onSidebar.enterCode, + onConversationSource: this.actions.onCompactConversationSource, + onOpenViewSettings: () => { + this.showRemoteViewSettings = true; + }, + onSearchQueryChange: (query: string) => { + if (this.compactSidebarSource() === ConversationSource.Remote) { + this.actions.onRemoteHome.queryChanged(query); + } + }, + onOpenSettings: () => { + this.compactSidebarSettings(this.compactSidebarSource()); + }, + onOpenAccount: this.actions.onSidebar.openAccount, onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession }) + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) } @Builder SettingsContent() { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index be4b21d0d9..08cec8c99d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -18,12 +18,16 @@ export struct AppSidebar { @Prop accountUserId: string = ''; @Prop showConversationSourceSwitcher: boolean = false; @Prop showCollapseButton: boolean = false; + @Prop showViewSettingsButton: boolean = false; + @Prop showCustomContent: boolean = false; @Prop conversationSource: ConversationSource = ConversationSource.General; onClose: () => void = () => {}; onNewChat: () => void = () => {}; onEnterCode: () => void = () => {}; onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; onCollapse: () => void = () => {}; + onOpenViewSettings: () => void = () => {}; + onSearchQueryChange: (query: string) => void = (_query: string) => {}; onOpenSettings: () => void = () => {}; onOpenAccount: () => void = () => {}; onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @@ -38,6 +42,12 @@ export struct AppSidebar { @State showSearch: boolean = false; @State sessionSearchQuery: string = ''; @State archivedSessionsExpanded: boolean = false; + /** + * Session content for the current conversation source. The shell around it + * (header, source switcher, content origin, footer) stays identical for every + * source so switching Local/Remote never moves shared chrome. + */ + @BuilderParam contentSlot: () => void = this.LocalSessionContent; build() { this.SidebarContent() @@ -67,6 +77,38 @@ export struct AppSidebar { .width('100%') .margin({ top: 18 }) + Stack({ alignContent: Alignment.Bottom }) { + Column() { + if (this.showCustomContent) { + this.contentSlot() + } else { + this.LocalSessionContent() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + + if (this.isAccountAuthenticated()) { + this.AuthenticatedFooter() + } else { + this.SignedOutFooter() + } + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + .padding({ left: 20, right: 20, top: 4, bottom: 16 }) + .backgroundColor(PAGE_BG) + .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) + .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) + } + + @Builder + LocalSessionContent() { + Column() { if (this.visiblePinnedSessions().length > 0) { Text('置顶') .fontSize(14).fontWeight(FontWeight.Medium).fontColor(MUTED) @@ -83,59 +125,46 @@ export struct AppSidebar { .width('100%') .margin({ top: 16, bottom: 6 }) - Stack({ alignContent: Alignment.Bottom }) { - List({ space: 2 }) { - if (this.visibleRecentSessions().length === 0) { + List({ space: 2 }) { + if (this.visibleRecentSessions().length === 0) { + ListItem() { + this.EmptyRecent() + } + } + Repeat(this.visibleRecentSessions()) + .each((obj: RepeatItem) => { ListItem() { - this.EmptyRecent() + this.RecentRow(obj) } + }) + .key((item: RemoteSession) => item.id) + .virtualScroll({ totalCount: this.visibleRecentSessions().length }) + if (this.archivedSessionCount() > 0) { + ListItem() { + this.ArchivedDisclosureRow() } - Repeat(this.visibleRecentSessions()) + } + if (this.archivedSessionsExpanded) { + Repeat(this.visibleArchivedSessions()) .each((obj: RepeatItem) => { ListItem() { this.RecentRow(obj) } }) .key((item: RemoteSession) => item.id) - .virtualScroll({ totalCount: this.visibleRecentSessions().length }) - if (this.archivedSessionCount() > 0) { - ListItem() { - this.ArchivedDisclosureRow() - } - } - if (this.archivedSessionsExpanded) { - Repeat(this.visibleArchivedSessions()) - .each((obj: RepeatItem) => { - ListItem() { - this.RecentRow(obj) - } - }) - .key((item: RemoteSession) => item.id) - .virtualScroll({ totalCount: this.visibleArchivedSessions().length }) - } - } - .width('100%') - .height('100%') - .margin({ left: 0 }) - .padding({ bottom: 84 }) - .scrollBar(BarState.Off) - .divider(null) - - if (this.isAccountAuthenticated()) { - this.AuthenticatedFooter() - } else { - this.SignedOutFooter() + .virtualScroll({ totalCount: this.visibleArchivedSessions().length }) } } .width('100%') .layoutWeight(1) + .margin({ left: 0 }) + .padding({ bottom: 84 }) + .scrollBar(BarState.Off) + .divider(null) } .width('100%') .height('100%') - .padding({ left: 20, right: 20, top: 4, bottom: 16 }) - .backgroundColor(PAGE_BG) - .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) - .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) + .alignItems(HorizontalAlign.Start) } @Builder @@ -146,7 +175,25 @@ export struct AppSidebar { .fontWeight(FontWeight.Bold) .fontColor(INK) Blank() + // Right-aligned so the search and collapse controls keep the same + // position whether or not the source-specific view-settings entry shows. Row({ space: 6 }) { + if (this.showViewSettingsButton) { + Stack({ alignContent: Alignment.Center }) { + this.MoreDotsGlyph() + } + .width(38) + .height(38) + .backgroundColor(CARD) + .border({ width: 1, color: LINE }) + .borderRadius(19) + .shadow({ radius: 14, color: '#16000000', offsetY: 6 }) + .accessibilityText(RemoteI18n.t('remote.actions')) + .onClick(() => { + this.onOpenViewSettings(); + }) + } + Stack({ alignContent: Alignment.Center }) { this.SearchGlyph() } @@ -160,7 +207,7 @@ export struct AppSidebar { .onClick(() => { this.showSearch = !this.showSearch; if (!this.showSearch) { - this.sessionSearchQuery = ''; + this.updateSearchQuery(''); } }) @@ -188,11 +235,16 @@ export struct AppSidebar { .borderRadius(8) .margin({ top: 12 }) .onChange((value: string) => { - this.sessionSearchQuery = value; + this.updateSearchQuery(value); }) } } + private updateSearchQuery(value: string): void { + this.sessionSearchQuery = value; + this.onSearchQueryChange(value); + } + @Builder private SignedOutHeader() { Row() { @@ -424,16 +476,21 @@ export struct AppSidebar { }) } + @Builder + private MoreDotsGlyph() { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + @Builder private SessionMoreButton(session: RemoteSession) { Stack({ alignContent: Alignment.Center }) { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) + this.MoreDotsGlyph() } .width(34) .height(40) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets deleted file mode 100644 index e1fc4ec667..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteActionsSheet.ets +++ /dev/null @@ -1,312 +0,0 @@ -import { AssistantEntry, RecentWorkspaceEntry } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, GREEN, INK, LINE, MUTED, RED, SOFT, SUBTLE } from './Theme'; - -@ComponentV2 -export struct RemoteActionsSheet { - @Param desktopName: string = ''; - @Param workspaceName: string = 'BitFun'; - @Param workspacePath: string = ''; - @Param assistantId: string = ''; - @Param connectionState: string = 'idle'; - @Param isBusy: boolean = false; - @Param sortMode: string = 'project'; - @Param recentWorkspaces: RecentWorkspaceEntry[] = []; - @Param assistants: AssistantEntry[] = []; - @Param showWorkspacePicker: boolean = false; - @Param showAssistantPicker: boolean = false; - @Local selectedSortMode: string = 'project'; - @Event onClose: () => void = () => {}; - @Event onDismiss: () => void = () => {}; - @Event onRefresh: () => void = () => {}; - @Event onShowWorkspaces: () => void = () => {}; - @Event onShowAssistants: () => void = () => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - @Event onSelectAssistant: (path: string) => void = (_path: string) => {}; - @Event onCancelWorkspacePicker: () => void = () => {}; - @Event onCancelAssistantPicker: () => void = () => {}; - @Event onReconnect: () => void = () => {}; - @Event onDisconnect: () => void = () => {}; - @Event onClearPairing: () => void = () => {}; - @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; - @Event onOpenSettings: () => void = () => {}; - @Event onOpenViewSettings: () => void = () => {}; - - aboutToAppear(): void { - this.selectedSortMode = this.sortMode; - } - - build() { - Column() { - Scroll() { - Column({ space: 0 }) { - if (this.showWorkspacePicker) { - this.WorkspacePicker() - } else if (this.showAssistantPicker) { - this.AssistantPicker() - } else { - this.RemoteActionRows() - } - } - .width('100%') - .padding({ left: 16, right: 16, top: 14, bottom: 14 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(CARD) - .border({ width: 1, color: '#0D000000' }) - .borderRadius(20) - .shadow({ radius: 20, color: '#1A000000', offsetY: 8 }) - } - - @Builder - private RemoteActionRows() { - Column({ space: 0 }) { - this.SectionTitle(RemoteI18n.t('remote.menu.organize')) - this.IconRow('remote_actions_settings', RemoteI18n.t('viewSettings.title'), '', () => { - this.onDismiss(); - this.onOpenViewSettings(); - }) - Divider().strokeWidth(1).color(LINE).margin({ top: 8, bottom: 8 }) - this.SectionTitle(RemoteI18n.t('remote.menu.manage')) - this.IconRow('remote_actions_cloud', RemoteI18n.t('remote.menu.cloudTasks'), '', () => { - this.getUIContext().getPromptAction().showToast({ - message: RemoteI18n.t('remote.menu.comingSoon'), - duration: 1800 - }); - }) - this.IconRow('remote_actions_settings', RemoteI18n.t('remote.menu.settings'), '', () => { - this.onDismiss(); - this.onOpenSettings(); - }) - } - .width('100%') - .backgroundColor('#00000000') - } - - @Builder - private SectionTitle(title: string) { - Text(title) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .width('100%') - .height(28) - .padding({ left: 8 }) - .textAlign(TextAlign.Start) - } - - @Builder - private IconRow(icon: string, label: string, sortMode: string, action: () => void) { - Row({ space: 10 }) { - if (sortMode.length > 0 && this.selectedSortMode === sortMode) { - SymbolGlyph($r('sys.symbol.checkmark_circle')) - .fontSize(18) - .fontColor([INK]) - .width(20) - .height(20) - } else { - Blank().width(20) - } - this.ActionIcon(icon) - Text(label) - .fontSize(15) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(48) - .padding({ left: 8, right: 8 }) - .borderRadius(10) - .backgroundColor(sortMode.length > 0 && this.selectedSortMode === sortMode ? SOFT : '#00000000') - .onClick(action) - } - - @Builder - private ActionIcon(icon: string) { - if (icon === 'remote_actions_folder') { - SymbolGlyph($r('sys.symbol.folder')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } else if (icon === 'remote_actions_clock') { - SymbolGlyph($r('sys.symbol.clock')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } else if (icon === 'remote_actions_chat') { - SymbolGlyph($r('sys.symbol.message')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } else if (icon === 'remote_actions_cloud') { - SymbolGlyph($r('sys.symbol.cloud')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } else if (icon === 'remote_actions_link') { - SymbolGlyph($r('sys.symbol.link')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } else { - SymbolGlyph($r('sys.symbol.gearshape')).fontSize(20).fontColor([MUTED]).width(23).height(23) - } - } - - @Builder - private InfoRow(label: string, value: string) { - Row({ space: 14 }) { - Text(label) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(value) - .fontSize(14) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: 180 }) - } - .width('100%') - .height(54) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - } - - @Builder - private ActionRow(label: string, action: () => void, destructive: boolean = false) { - Row() { - Text(label) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(destructive ? RED : INK) - Blank() - Text('›') - .fontSize(21) - .fontColor(SUBTLE) - } - .width('100%') - .height(54) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private WorkspacePicker() { - Column({ space: 0 }) { - this.PickerHeader(RemoteI18n.t('home.recentWorkspaces'), () => { - this.onCancelWorkspacePicker(); - }) - if (this.recentWorkspaces.length === 0) { - this.PickerEmpty(this.isBusy ? RemoteI18n.t('common.loading') : RemoteI18n.t('home.noRecentWorkspaces')) - } else { - ForEach(this.recentWorkspaces, (item: RecentWorkspaceEntry) => { - this.PickerRow(item.name || this.basename(item.path), item.path, item.path === this.workspacePath, () => { - if (item.path !== this.workspacePath) { - this.onSelectWorkspace(item.path); - } - this.onDismiss(); - }) - }, (item: RecentWorkspaceEntry) => item.path) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - } - - @Builder - private AssistantPicker() { - Column({ space: 0 }) { - this.PickerHeader(RemoteI18n.t('home.assistantWorkspaces'), () => { - this.onCancelAssistantPicker(); - }) - if (this.assistants.length === 0) { - this.PickerEmpty(this.isBusy ? RemoteI18n.t('common.loading') : RemoteI18n.t('home.noAssistants')) - } else { - ForEach(this.assistants, (item: AssistantEntry) => { - this.PickerRow(item.name || this.basename(item.path), item.path, item.assistant_id === this.assistantId, () => { - this.onSelectAssistant(item.path); - this.onDismiss(); - }) - }, (item: AssistantEntry) => item.path) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - } - - @Builder - private PickerHeader(title: string, action: () => void) { - Row() { - Text('‹') - .fontSize(28) - .fontColor(INK) - Text(title) - .fontSize(17) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .margin({ left: 8 }) - } - .width('100%') - .height(54) - .padding({ left: 16, right: 16 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private PickerRow(title: string, detail: string, selected: boolean, action: () => void) { - Row({ space: 12 }) { - Column({ space: 3 }) { - Text(title) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(detail) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - Text(selected ? '✓' : '›') - .fontSize(selected ? 16 : 20) - .fontColor(selected ? GREEN : SUBTLE) - } - .width('100%') - .height(62) - .padding({ left: 18, right: 18 }) - .border({ width: { bottom: 1 }, color: LINE }) - .onClick(action) - } - - @Builder - private PickerEmpty(text: string) { - Text(text) - .width('100%') - .padding({ left: 18, right: 18, top: 22, bottom: 22 }) - .fontSize(14) - .lineHeight(20) - .fontColor(MUTED) - } - - private workspaceTitle(): string { - return this.workspaceName || 'BitFun'; - } - - private assistantTitle(): string { - return this.assistantId || RemoteI18n.t('remote.noAssistant'); - } - - private basename(path: string): string { - if (!path) { - return 'Workspace'; - } - const parts = path.replace(/\\/g, '/').split('/'); - return parts[parts.length - 1] || 'Workspace'; - } - - private selectSortMode(mode: string): void { - this.selectedSortMode = mode; - this.onSortModeChange(mode); - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets deleted file mode 100644 index 9bfee1a868..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteBottomBar.ets +++ /dev/null @@ -1,69 +0,0 @@ -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SUBTLE } from './Theme'; - -@ComponentV2 -export struct RemoteBottomBar { - @Param query: string = ''; - @Param isBusy: boolean = false; - @Event onQueryChange: (value: string) => void = (_value: string) => {}; - @Event onSearch: () => void = () => {}; - @Event onCreate: () => void = () => {}; - - build() { - Row({ space: 12 }) { - Row({ space: 8 }) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(20) - .fontColor([INK]) - TextInput({ placeholder: RemoteI18n.t('remote.searchChats'), text: this.query }) - .layoutWeight(1) - .height(48) - .fontSize(16) - .fontColor(INK) - .placeholderColor(SUBTLE) - .padding({ left: 0, right: 4 }) - .backgroundColor('#00000000') - .onChange((value: string) => { - this.onQueryChange(value); - }) - } - .layoutWeight(1) - .height(48) - .padding({ left: 6, right: 10 }) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 20, color: '#12000000', offsetY: 7 }) - .onClick(() => { - if (this.query.trim().length > 0) { - this.onSearch(); - } - }) - - Button() { - Row({ space: 9 }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(20) - .fontColor([PRIMARY_ACTION_TEXT]) - Text(RemoteI18n.t('remote.newChat')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - } - } - .width(118) - .height(48) - .padding(0) - .backgroundColor(PRIMARY_ACTION) - .borderRadius(24) - .shadow({ radius: 20, color: '#18000000', offsetY: 7 }) - .enabled(!this.isBusy) - .onClick(() => { - this.onCreate(); - }) - } - .width('91%') - .alignSelf(ItemAlign.Center) - .alignItems(VerticalAlign.Center) - .translate({ y: 15 }) - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets deleted file mode 100644 index 29412ed5da..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHeader.ets +++ /dev/null @@ -1,119 +0,0 @@ -import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; -import { CARD, GREEN, INK, MUTED, RED } from './Theme'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CompactMenuButton } from './CompactMenuButton'; - -@ComponentV2 -export struct RemoteHeader { - @Param desktopName: string = ''; - @Param connectionState: string = 'idle'; - @Param isLoading: boolean = false; - @Event onOpenSidebar: () => void = () => {}; - @Event onOpenActions: () => void = () => {}; - @Event onOpenDevices: () => void = () => {}; - - build() { - Row() { - this.MenuButton() - Column({ space: 2 }) { - Text(RemoteI18n.t('remote.title')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .maxLines(1) - Row({ space: 6 }) { - if (this.isLoading || this.isConnecting()) { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - } else { - Text('') - .width(7) - .height(7) - .backgroundColor(this.connectionColor()) - .borderRadius(4) - } - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(16) - .fontColor([MUTED]) - .width(19) - .height(18) - Text(this.desktopName || RemoteI18n.t('remote.settings.noDesktop')) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(11) - .fontColor([MUTED]) - .width(13) - .height(13) - .opacity(0.58) - } - .height(28) - .padding({ left: 8, right: 8 }) - .borderRadius(10) - .accessibilityText(RemoteI18n.t('connect.accountDevicesTitle')) - .onClick(() => { - this.onOpenDevices(); - }) - } - .layoutWeight(1) - .height(52) - .alignItems(HorizontalAlign.Center) - .justifyContent(FlexAlign.Center) - this.MoreButton() - } - .width('100%') - .height(68) - .alignItems(VerticalAlign.Center) - } - - @Builder - private MenuButton() { - CompactMenuButton({ - onOpen: () => { - this.onOpenSidebar(); - } - }) - } - - @Builder - private MoreButton() { - Stack({ alignContent: Alignment.Center }) { - Row({ space: 4 }) { - Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) - Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) - Text('').width(4).height(4).backgroundColor(MUTED).borderRadius(2) - } - .height(10) - .alignItems(VerticalAlign.Center) - } - .width(48) - .height(48) - .backgroundColor(CARD) - .borderRadius(24) - .shadow({ radius: 18, color: '#10000000', offsetY: 8 }) - .onClick(() => { - this.onOpenActions(); - }) - } - - private connectionColor(): ResourceColor { - const tone = ConnectionStatusPresenter.tone(this.connectionState); - if (tone === 'ok') { - return GREEN; - } - if (tone === 'error') { - return RED; - } - return MUTED; - } - - private isConnecting(): boolean { - return this.connectionState === 'parsing' || - this.connectionState === 'pairing' || - this.connectionState === 'reconnecting'; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets deleted file mode 100644 index eb8b9dabb3..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteHomeView.ets +++ /dev/null @@ -1,370 +0,0 @@ -import { RemoteSession } from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemoteActionsSheet } from './RemoteActionsSheet'; -import { RemoteBottomBar } from './RemoteBottomBar'; -import { RemoteHeader } from './RemoteHeader'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { RemotePageState } from '../state/RemotePageState'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { CARD, INK, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT } from './Theme'; -import { ConversationViewSettings } from './ConversationViewSettings'; - -@ComponentV2 -export struct RemoteHomeView { - @Param pageState: RemotePageState = new RemotePageState(); - @Param isBusy: boolean = false; - @Param selectedSessionId: string = ''; - @Param sortMode: string = 'project'; - @Param workspaceFilter: string = ''; - @Param agentFilter: string = ''; - @Param statusFilter: string = ''; - @Param showWorkspaceMetadata: boolean = false; - @Param showUpdatedMetadata: boolean = false; - @Param showStatusMetadata: boolean = false; - @Event onOpenSidebar: () => void = () => {}; - @Event onConnectWorkspace: () => void = () => {}; - @Event onAddConnection: () => void = () => {}; - @Event onOpenRemoteSettings: () => void = () => {}; - @Event onRefresh: () => void = () => {}; - @Event onShowWorkspaces: () => void = () => {}; - @Event onShowAssistants: () => void = () => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - @Event onSelectAssistant: (path: string) => void = (_path: string) => {}; - @Event onCancelWorkspacePicker: () => void = () => {}; - @Event onCancelAssistantPicker: () => void = () => {}; - @Event onSessionQueryChange: (value: string) => void = (_value: string) => {}; - @Event onSearchSessions: () => void = () => {}; - @Event onLoadMoreSessions: () => void = () => {}; - @Event onReconnect: () => void = () => {}; - @Event onDisconnect: () => void = () => {}; - @Event onClearPairing: () => void = () => {}; - @Event onCreate: (agentType: string) => void = (_agentType: string) => {}; - @Event onCreateAssistantSession: () => void = () => {}; - @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; - @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onSortModeChange: (mode: string) => void = (_mode: string) => {}; - @Event onWorkspaceFilterChange: (value: string) => void = (_value: string) => {}; - @Event onAgentFilterChange: (value: string) => void = (_value: string) => {}; - @Event onStatusFilterChange: (value: string) => void = (_value: string) => {}; - @Event onWorkspaceMetadataChange: (value: boolean) => void = (_value: boolean) => {}; - @Event onUpdatedMetadataChange: (value: boolean) => void = (_value: boolean) => {}; - @Event onStatusMetadataChange: (value: boolean) => void = (_value: boolean) => {}; - @Local showRemoteActions: boolean = false; - @Local showViewSettings: boolean = false; - - build() { - Stack() { - Column() { - RemoteHeader({ - desktopName: this.pageState.desktopName, - connectionState: this.pageState.connectionState, - isLoading: this.pageState.isLoadingHome, - onOpenSidebar: () => { - this.onOpenSidebar(); - }, - onOpenActions: () => { - this.openRemoteActions(); - }, - onOpenDevices: () => { - this.onAddConnection(); - } - }) - if (this.isInitialLoading()) { - RemoteSessionLoadingView() - RemoteBottomBar({ - query: this.pageState.sessionQuery, - isBusy: true, - onQueryChange: (value: string) => { - this.onSessionQueryChange(value); - }, - onSearch: () => { - this.onSearchSessions(); - }, - onCreate: () => { - this.onCreateAssistantSession(); - } - }) - } else if (this.canUseRemote()) { - RemoteSessionList({ - sessions: this.pageState.visibleSessions(), - query: this.pageState.sessionQuery, - sortMode: this.sortMode, - workspaceFilter: this.workspaceFilter, - agentFilter: this.agentFilter, - statusFilter: this.statusFilter, - workspaceName: this.pageState.workspaceName, - workspacePath: this.pageState.workspacePath, - workspaceKind: this.pageState.workspaceKind, - recentWorkspaces: this.pageState.recentWorkspaces, - showWorkspaceMetadata: this.showWorkspaceMetadata, - showUpdatedMetadata: this.showUpdatedMetadata, - showStatusMetadata: this.showStatusMetadata, - hasMoreSessions: this.pageState.hasMoreSessions, - isBusy: this.isBusy || this.pageState.isLoadingSessions, - onCreate: () => { - this.onCreate('code'); - }, - onCreateAssistantSession: () => { - this.onCreateAssistantSession(); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.onCreateInWorkspace(path, agentType); - }, - onSelectWorkspace: (path: string) => { - this.onSelectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.onOpenSession(session); - }, - onDeleteSession: (session: RemoteSession) => { - this.onDeleteSession(session); - }, - selectedSessionId: this.selectedSessionId, - onLoadMore: () => { - this.onLoadMoreSessions(); - } - }) - RemoteBottomBar({ - query: this.pageState.sessionQuery, - isBusy: this.isBusy, - onQueryChange: (value: string) => { - this.onSessionQueryChange(value); - }, - onSearch: () => { - this.onSearchSessions(); - }, - onCreate: () => { - this.onCreateAssistantSession(); - } - }) - } else { - this.DisconnectedState() - } - } - if (this.showRemoteActions) { - Stack({ alignContent: Alignment.TopEnd }) { - Text('') - .width('100%') - .height('100%') - .backgroundColor('#06000000') - .onClick(() => { - this.dismissRemoteActions(); - }) - Column() { - this.RemoteActionsLayer() - } - .width(280) - .height(252) - .margin({ top: 4, right: 4 }) - .transition(TransitionEffect.translate({ x: 18, y: -12 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseOut })) - } - .width('100%') - .height('100%') - } - } - .width('100%') - .height('100%') - .padding({ left: 12, right: 12, top: 0, bottom: 16 }) - .backgroundColor(PAGE_BG) - .bindSheet($$this.showViewSettings, this.ViewSettingsSheet(), this.viewSettingsSheetOptions()) - } - - @Builder - RemoteActionsLayer() { - RemoteActionsSheet({ - desktopName: this.pageState.desktopName, - workspaceName: this.pageState.workspaceName, - workspacePath: this.pageState.workspacePath, - assistantId: this.pageState.assistantId, - connectionState: this.pageState.connectionState, - isBusy: this.isBusy, - sortMode: this.sortMode, - recentWorkspaces: this.pageState.recentWorkspaces, - assistants: this.pageState.assistants, - showWorkspacePicker: this.pageState.showWorkspacePicker, - showAssistantPicker: this.pageState.showAssistantPicker, - onClose: () => { - this.closeRemoteActions(); - }, - onDismiss: () => { - this.dismissRemoteActions(); - }, - onRefresh: () => { - this.onRefresh(); - }, - onShowWorkspaces: () => { - this.onShowWorkspaces(); - }, - onShowAssistants: () => { - this.onShowAssistants(); - }, - onSelectWorkspace: (path: string) => { - this.onSelectWorkspace(path); - }, - onSelectAssistant: (path: string) => { - this.onSelectAssistant(path); - }, - onCancelWorkspacePicker: () => { - this.onCancelWorkspacePicker(); - }, - onCancelAssistantPicker: () => { - this.onCancelAssistantPicker(); - }, - onReconnect: () => { - this.onReconnect(); - }, - onDisconnect: () => { - this.onDisconnect(); - }, - onClearPairing: () => { - this.onClearPairing(); - }, - onSortModeChange: (mode: string) => { - this.onSortModeChange(mode); - }, - onOpenSettings: () => { - this.onOpenRemoteSettings(); - }, - onOpenViewSettings: () => { - this.showViewSettings = true; - } - }) - } - - @Builder - ViewSettingsSheet() { - ConversationViewSettings({ - sessions: this.pageState.visibleSessions(), - workspaceName: this.pageState.workspaceName, - workspacePath: this.pageState.workspacePath, - workspaceKind: this.pageState.workspaceKind, - recentWorkspaces: this.pageState.recentWorkspaces, - sortMode: this.sortMode, - workspaceFilter: this.workspaceFilter, - agentFilter: this.agentFilter, - statusFilter: this.statusFilter, - showWorkspaceMetadata: this.showWorkspaceMetadata, - showUpdatedMetadata: this.showUpdatedMetadata, - showStatusMetadata: this.showStatusMetadata, - onSortModeChange: (mode: string) => { - this.onSortModeChange(mode); - }, - onWorkspaceFilterChange: (value: string) => { - RemoteLogger.info(`compact view-settings workspace received=${value.length > 0 ? value : ''}`); - this.onWorkspaceFilterChange(value); - }, - onAgentFilterChange: (value: string) => { - this.onAgentFilterChange(value); - }, - onStatusFilterChange: (value: string) => { - this.onStatusFilterChange(value); - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.onWorkspaceMetadataChange(value); - }, - onUpdatedMetadataChange: (value: boolean) => { - this.onUpdatedMetadataChange(value); - }, - onStatusMetadataChange: (value: boolean) => { - this.onStatusMetadataChange(value); - }, - onClose: () => { - this.showViewSettings = false; - } - }) - } - - @Builder - DisconnectedState() { - Column({ space: 10 }) { - this.LargeDesktopGlyph() - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .margin({ top: 8 }) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('connect.connect')) - .width(148) - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(25) - .margin({ top: 14 }) - .onClick(() => { - this.onConnectWorkspace(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ bottom: 72 }) - } - - @Builder - LargeDesktopGlyph() { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(50) - .fontColor([INK]) - } - .width(64) - .height(58) - } - - private closeRemoteActions(): void { - this.onCancelWorkspacePicker(); - this.onCancelAssistantPicker(); - this.dismissRemoteActions(); - } - - private openRemoteActions(): void { - this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => { - this.showRemoteActions = true; - }); - } - - private dismissRemoteActions(): void { - this.getUIContext().animateTo({ duration: 180, curve: Curve.EaseOut }, () => { - this.showRemoteActions = false; - }); - } - - private canUseRemote(): boolean { - return this.pageState.connectionState === 'connected'; - } - - private isConnecting(): boolean { - return this.pageState.connectionState === 'parsing' || - this.pageState.connectionState === 'pairing' || - this.pageState.connectionState === 'reconnecting'; - } - - private isInitialLoading(): boolean { - return this.pageState.isLoadingHome || this.isConnecting(); - } - - private viewSettingsSheetOptions(): SheetOptions { - return { - height: 520, - backgroundColor: '#00000000', - maskColor: '#44000000', - showClose: false, - dragBar: true - }; - } - -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets index 92c99512df..b4b50b623b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets @@ -642,6 +642,7 @@ export class AppRootRuntime { (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), (): void => this.closeAppSidebar(), (source: ConversationSource): void => { this.switchWideConversationSource(source); }, + (source: ConversationSource): void => { this.switchCompactConversationSource(source); }, (): void => this.enterCompactLayout(), new RemoteHomePresentationActions( (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), @@ -1291,6 +1292,37 @@ export class AppRootRuntime { } } + /** + * Compact counterpart of switchWideConversationSource. Switching source is a + * change of context, not a command to start something: it resumes the session + * the user was last in, and otherwise rests on the Remote landing surface + * rather than opening the create composer for them. + */ + async switchCompactConversationSource(source: ConversationSource): Promise { + this.closeAppSidebar(); + if (AppRouteContract.conversationSource(this.currentRoute()) === source) { + return; + } + if (this.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.stopPolling(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.persistVisibleGeneralChatDraft(); + const activeRemoteSessionId = RemoteUiState.canUseRemote(this.connectionState) ? + (this.remotePageState.activeSession.sessionId || '') : ''; + if (activeRemoteSessionId.length === 0) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); + this.startPolling(); + await this.loadActiveMessages(); + } + private enterCompactLayout(): void { const sessionId = this.remotePageState.activeSession.sessionId || ''; if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index 2f79fe98ed..b9009f449f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -1,5 +1,13 @@ +import { AppRoute } from '../navigation/AppRouteContract'; + @ObservedV2 export class AppShellState { + /** + * Mirror of the navigation stack top. NavPathStack is not observable, so + * surfaces that live outside Navigation — the drawer above all — have no way + * to follow the route without this traced copy. + */ + @Trace activeRoute: AppRoute = AppRoute.ChatHome; @Trace showSidebar: boolean = false; @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets index 0f182e27ef..53bf57347e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets @@ -34,9 +34,10 @@ export class AppShellViewModel { } if (spec.hasSessionParam()) { this.navigationStack.pushPath({ name: spec.name, param: spec.routeParam() }, animated); - return; + } else { + this.navigationStack.pushPath({ name: spec.name }, animated); } - this.navigationStack.pushPath({ name: spec.name }, animated); + this.syncActiveRoute(); } replaceRoute(route: AppRoute, sessionId: string = ''): void { @@ -44,6 +45,7 @@ export class AppShellViewModel { if (route !== AppRoute.ChatHome) { this.pushRoute(route, sessionId); } + this.syncActiveRoute(); } replaceRouteWithoutAnimation(route: AppRoute, sessionId: string = ''): void { @@ -54,6 +56,7 @@ export class AppShellViewModel { if (route !== AppRoute.ChatHome) { this.pushRoute(route, sessionId, false); } + this.syncActiveRoute(); } replaceCurrentRoute(route: AppRoute, sessionId: string = ''): void { @@ -66,19 +69,29 @@ export class AppShellViewModel { name: route, param: AppRouteContract.routeParam(sessionId) }, false); - return; + } else { + this.navigationStack.replacePath({ name: route }, false); } - this.navigationStack.replacePath({ name: route }, false); + this.syncActiveRoute(); } popRoute(fallback: AppRoute): void { if (this.navigationStack.getAllPathName().length > 0) { this.navigationStack.pop(); + this.syncActiveRoute(); return; } this.replaceRoute(fallback); } + /** + * Republish the stack top as traced state. Every navigation goes through this + * view model, so this is the one place the mirror can be kept honest. + */ + private syncActiveRoute(): void { + this.state.activeRoute = this.currentRoute(); + } + backAction(route: AppRoute): AppNavigationBackAction { return AppRouteContract.backAction(route, this.state.showSidebar); } From 7695402943ee5700c9a5bcc9a4282594684a1124 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 07:12:01 -0700 Subject: [PATCH 022/206] fix(session): make context usage persistence authoritative --- src/apps/desktop/src/api/agentic_api.rs | 9 +- src/apps/desktop/src/lib.rs | 8 + .../src/runtime/session_application.rs | 51 +- .../src/agentic/coordination/coordinator.rs | 1 + .../core/src/agentic/session/context_usage.rs | 157 ++++ .../assembly/core/src/agentic/session/mod.rs | 2 + .../src/agentic/session/session_manager.rs | 194 ++++- .../assembly/core/src/agentic/system.rs | 21 +- .../services-core/src/session/lineage.rs | 60 +- .../services-core/src/session/metadata.rs | 8 + .../services-core/src/session/types.rs | 48 ++ .../tests/session_metadata_contracts.rs | 72 +- .../EventHandlerModule.test.ts | 78 +- .../flow-chat-manager/EventHandlerModule.ts | 34 +- .../flow-chat-manager/PersistenceModule.ts | 80 -- .../src/flow_chat/store/FlowChatStore.test.ts | 707 +++++++++++++++++- .../src/flow_chat/store/FlowChatStore.ts | 187 ++++- src/web-ui/src/flow_chat/types/flow-chat.ts | 5 + .../flow_chat/utils/tokenUsageDisplay.test.ts | 39 +- .../src/flow_chat/utils/tokenUsageDisplay.ts | 40 +- .../api/service-api/AgentAPI.ts | 2 + .../src/shared/types/session-history.ts | 13 + 22 files changed, 1624 insertions(+), 192 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/session/context_usage.rs diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 52d4ec3532..a870ffb92c 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -17,8 +17,8 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, - AgentInputAttachment, AgentSessionCreateResult, AgentSessionModelSelection, - AgentSessionModeUpdateRequest, AgentSessionModelSelectionUpdateRequest, + AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest, + AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, @@ -54,7 +54,7 @@ use bitfun_core::service::config::project_permission_store::{ use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use bitfun_core::service::session::{ - DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, + DialogTurnData, SessionContextUsage, SessionMemoryMode, SessionMetadata, SessionRelationship, SessionRelationshipKind, SessionTurnCatalog, SessionTurnWindowResponse, }; use bitfun_core::service::workspace::WorkspaceKind; @@ -502,6 +502,7 @@ pub struct RestoreSessionWithTurnsResponse { pub struct RestoreSessionViewResponse { pub session: SessionResponse, pub turns: Vec, + pub current_context_usage: Option, pub turn_catalog: SessionTurnCatalog, pub context_restore_state: String, pub is_partial: bool, @@ -3072,6 +3073,7 @@ pub async fn restore_session_view( .map_err(|error| format!("Failed to restore session view: {error}"))?; let session = restored.session; let mut turns = restored.turns; + let current_context_usage = restored.current_context_usage; let total_turn_count = restored.total_turn_count; let turn_catalog = restored.turn_catalog; let timings = restored.timings; @@ -3124,6 +3126,7 @@ pub async fn restore_session_view( Ok(RestoreSessionViewResponse { session: session_to_response_with_turn_count(session, total_turn_count), turns, + current_context_usage, turn_catalog, context_restore_state: "pending".to_string(), is_partial, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index efb6381dcb..b05a671fd6 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1944,6 +1944,14 @@ async fn init_agentic_system() -> anyhow::Result<( bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()), ); event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); + event_router.subscribe_internal( + "session_context_usage".to_string(), + Arc::new( + bitfun_core::agentic::session::SessionContextUsageSubscriber::new( + session_manager.clone(), + ), + ), + ); event_router.subscribe_internal( "thread_goal_tokens".to_string(), Arc::new(bitfun_core::agentic::goal_mode::ThreadGoalTokenSubscriber), diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 17997b7ac5..671bbc3add 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -25,8 +25,9 @@ use bitfun_core::service::remote_ssh::workspace_state::{ }; use bitfun_core::service::remote_ssh::SSHConnectionManager; use bitfun_core::service::session::{ - DialogTurnData, DialogTurnKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, SessionTurnCatalog, SessionTurnWindowResponse, + DialogTurnData, DialogTurnKind, SessionContextUsage, SessionMetadata, SessionStatus, + SessionTranscriptExport, SessionTranscriptExportOptions, SessionTurnCatalog, + SessionTurnWindowResponse, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::token_usage::TokenUsageService; @@ -36,12 +37,7 @@ use bitfun_runtime_ports::{AgentContextReloadRequest, SessionTurnWindowRequest}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -const UI_CUSTOM_METADATA_KEYS: [&str; 4] = [ - "titleSource", - "titleKey", - "titleParams", - "lastRequestTokenUsage", -]; +const UI_CUSTOM_METADATA_KEYS: [&str; 3] = ["titleSource", "titleKey", "titleParams"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -129,6 +125,7 @@ fn local_command_turn_record_request( pub(crate) struct DesktopSessionViewRestore { pub session: Session, pub turns: Vec, + pub current_context_usage: Option, pub total_turn_count: usize, pub turn_catalog: SessionTurnCatalog, pub timings: SessionViewRestoreTiming, @@ -827,10 +824,17 @@ impl DesktopSessionApplication { .loaded_session_snapshot(session_id) .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; overlay_live_session_state(&mut session, live_session); + let current_context_usage = self + .compatibility + .load_persisted_session_metadata(&storage_path, session_id) + .await + .map_err(desktop_core_session_error)? + .and_then(|metadata| metadata.current_context_usage); timings.resolve_storage_path_duration_ms = resolve_storage_path_duration_ms; Ok(DesktopSessionViewRestore { session, turns, + current_context_usage, total_turn_count, turn_catalog, timings, @@ -940,7 +944,9 @@ mod tests { AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, PortError, PortErrorKind, PortResult, }; - use bitfun_core::service::session::{SessionKind, SessionMemoryMode}; + use bitfun_core::service::session::{ + SessionContextUsage, SessionContextUsageSource, SessionKind, SessionMemoryMode, + }; use serde_json::json; use std::sync::Mutex; @@ -1364,6 +1370,14 @@ mod tests { "titleSource": "i18n", "titleKey": "old" })); + current.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-7".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }); let mut incoming = current.clone(); incoming.session_name = "Renamed".to_string(); @@ -1373,6 +1387,14 @@ mod tests { incoming.status = SessionStatus::Active; incoming.turn_count = 1; incoming.review_action_state = Some(json!({ "phase": "fixing" })); + incoming.current_context_usage = Some(SessionContextUsage { + turn_id: "stale-turn".to_string(), + input_tokens: 1, + output_tokens: None, + total_tokens: 1, + timestamp: 1, + source: SessionContextUsageSource::ContextCompression, + }); incoming.custom_metadata = Some(json!({ "titleSource": "i18n", "titleKey": "new", @@ -1400,6 +1422,17 @@ mod tests { assert_eq!(current.status, SessionStatus::Archived); assert_eq!(current.turn_count, 7); assert_eq!(current.review_action_state, incoming.review_action_state); + assert_eq!( + current.current_context_usage, + Some(SessionContextUsage { + turn_id: "turn-7".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }) + ); let custom = current.custom_metadata.unwrap(); assert_eq!(custom["threadGoal"]["objective"], "preserve"); assert_eq!(custom["titleKey"], "new"); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index e95fefc0bb..aaad99038e 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -2665,6 +2665,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet snapshot_session_id: None, tags: Vec::new(), custom_metadata: None, + current_context_usage: None, relationship: None, todos: None, review_action_state: None, diff --git a/src/crates/assembly/core/src/agentic/session/context_usage.rs b/src/crates/assembly/core/src/agentic/session/context_usage.rs new file mode 100644 index 0000000000..f2bf44b824 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/context_usage.rs @@ -0,0 +1,157 @@ +use super::SessionManager; +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use bitfun_agent_runtime::event_bus::EventSubscriberResult; +use bitfun_services_core::session::{SessionContextUsage, SessionContextUsageSource}; +use log::warn; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Persists the runtime-owned context usage used by every product surface. +pub struct SessionContextUsageSubscriber { + session_manager: Arc, +} + +impl SessionContextUsageSubscriber { + pub fn new(session_manager: Arc) -> Self { + Self { session_manager } + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +fn usage_from_event(event: &AgenticEvent) -> Option<(&str, SessionContextUsage)> { + match event { + AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + input_tokens, + output_tokens, + total_tokens, + .. + } => Some(( + session_id, + SessionContextUsage { + turn_id: turn_id.clone(), + input_tokens: *input_tokens as u64, + output_tokens: output_tokens.map(|value| value as u64), + total_tokens: *total_tokens as u64, + timestamp: now_ms(), + source: SessionContextUsageSource::ModelRequest, + }, + )), + AgenticEvent::ContextCompressionCompleted { + session_id, + turn_id, + tokens_after, + applied: true, + .. + } => Some(( + session_id, + SessionContextUsage { + turn_id: turn_id.clone(), + input_tokens: *tokens_after as u64, + output_tokens: None, + total_tokens: *tokens_after as u64, + timestamp: now_ms(), + source: SessionContextUsageSource::ContextCompression, + }, + )), + _ => None, + } +} + +#[async_trait::async_trait] +impl EventSubscriber for SessionContextUsageSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { + let Some((session_id, usage)) = usage_from_event(event) else { + return Ok(()); + }; + + if let Err(error) = self + .session_manager + .persist_current_context_usage(session_id, usage) + .await + { + warn!( + "Failed to persist session context usage: session_id={}, error={}", + session_id, error + ); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_model_request_usage() { + let event = AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "model".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + max_context_tokens: Some(128_000), + is_subagent: false, + cached_tokens: None, + token_details: None, + }; + + let (session_id, usage) = usage_from_event(&event).expect("usage event"); + assert_eq!(session_id, "session-1"); + assert_eq!(usage.turn_id, "turn-1"); + assert_eq!(usage.input_tokens, 42_000); + assert_eq!(usage.output_tokens, Some(1_500)); + assert_eq!(usage.total_tokens, 43_500); + assert_eq!(usage.source, SessionContextUsageSource::ModelRequest); + } + + #[test] + fn maps_only_applied_context_compression() { + let event = AgenticEvent::ContextCompressionCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + compression_id: "compression-1".to_string(), + compression_count: 1, + tokens_before: 90_000, + tokens_after: 15_000, + compression_ratio: 0.17, + duration_ms: 500, + has_summary: true, + summary_source: "model".to_string(), + applied: true, + }; + + let (_, usage) = usage_from_event(&event).expect("applied compression"); + assert_eq!(usage.input_tokens, 15_000); + assert_eq!(usage.output_tokens, None); + assert_eq!(usage.total_tokens, 15_000); + assert_eq!(usage.source, SessionContextUsageSource::ContextCompression); + + let not_applied = AgenticEvent::ContextCompressionCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + compression_id: "compression-1".to_string(), + compression_count: 1, + tokens_before: 90_000, + tokens_after: 15_000, + compression_ratio: 0.17, + duration_ms: 500, + has_summary: true, + summary_source: "model".to_string(), + applied: false, + }; + assert!(usage_from_event(¬_applied).is_none()); + } +} diff --git a/src/crates/assembly/core/src/agentic/session/mod.rs b/src/crates/assembly/core/src/agentic/session/mod.rs index 5d8219553d..2a9db73749 100644 --- a/src/crates/assembly/core/src/agentic/session/mod.rs +++ b/src/crates/assembly/core/src/agentic/session/mod.rs @@ -4,6 +4,7 @@ pub mod compression; pub mod context_store; +mod context_usage; pub mod evidence_ledger; pub mod file_read_state; pub mod prompt_cache; @@ -16,6 +17,7 @@ pub mod turn_skill_agent_snapshot_store; pub use compression::*; pub use context_store::*; +pub use context_usage::*; pub use evidence_ledger::*; pub use file_read_state::*; pub use prompt_cache::*; diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 0d3b41c283..aba2a8b11d 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -38,9 +38,9 @@ use crate::service::config::{ }; use crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST; use crate::service::session::{ - DialogTurnData, DialogTurnKind, ModelRoundData, SessionMemoryMode, SessionMetadata, - SessionRelationship, SessionStatus, TextItemData, ThinkingItemData, ToolCallData, ToolItemData, - ToolResultData, TranscriptLineRange, TurnStatus, UserMessageData, + DialogTurnData, DialogTurnKind, ModelRoundData, SessionContextUsage, SessionMemoryMode, + SessionMetadata, SessionRelationship, SessionStatus, TextItemData, ThinkingItemData, + ToolCallData, ToolItemData, ToolResultData, TranscriptLineRange, TurnStatus, UserMessageData, }; use crate::service::snapshot::{ ensure_snapshot_manager_for_workspace, get_or_create_snapshot_manager, @@ -6252,6 +6252,30 @@ impl SessionManager { .await } + pub(crate) async fn persist_current_context_usage( + &self, + session_id: &str, + usage: SessionContextUsage, + ) -> BitFunResult<()> { + let _mutation_guard = self.acquire_session_mutation(session_id).await?; + let should_persist_usage = self.sessions.get(session_id).is_some_and(|session| { + !session.agent_type.starts_with("acp:") + && session + .dialog_turn_ids + .iter() + .any(|turn_id| turn_id == &usage.turn_id) + && self.should_persist_session(&session) + }); + if !should_persist_usage || !self.config.enable_persistence { + return Ok(()); + } + + self.update_persisted_session_metadata(session_id, |metadata| { + metadata.current_context_usage = Some(usage); + }) + .await + } + pub async fn merge_session_relationship( &self, session_id: &str, @@ -8007,9 +8031,10 @@ mod tests { AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, }; use crate::service::session::{ - DialogTurnData, DialogTurnKind, ModelRoundData, SessionKind, SessionMetadata, - SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, - TurnStatus, UserMessageData, + DialogTurnData, DialogTurnKind, ModelRoundData, SessionContextUsage, + SessionContextUsageSource, SessionKind, SessionMetadata, SessionRelationship, + SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, TurnStatus, + UserMessageData, }; use crate::util::errors::BitFunError; use bitfun_core_types::{ @@ -8228,6 +8253,163 @@ mod tests { ) } + #[tokio::test] + async fn current_context_usage_is_persisted_by_the_session_owner() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Usage persistence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..SessionConfig::default() + }, + ) + .await + .expect("session should create"); + let usage = SessionContextUsage { + turn_id: "turn-1".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }; + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids + .push(usage.turn_id.clone()); + + manager + .persist_current_context_usage(&session.session_id, usage.clone()) + .await + .expect("usage should persist"); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.current_context_usage, Some(usage)); + } + + #[tokio::test] + async fn acp_context_usage_is_not_persisted_as_native_prompt_usage() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "ACP usage".to_string(), + "acp:codex".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..SessionConfig::default() + }, + ) + .await + .expect("session should create"); + + manager + .persist_current_context_usage( + &session.session_id, + SessionContextUsage { + turn_id: "turn-1".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }, + ) + .await + .expect("ACP usage should be ignored"); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert!(metadata.current_context_usage.is_none()); + } + + #[tokio::test] + async fn delayed_context_usage_does_not_restore_a_removed_turn() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = Arc::new(test_manager(persistence_manager.clone())); + let session = manager + .create_session( + "Delayed usage".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..SessionConfig::default() + }, + ) + .await + .expect("session should create"); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids + .push("turn-1".to_string()); + + let mutation_guard = manager + .acquire_session_mutation(&session.session_id) + .await + .expect("mutation guard"); + let delayed_manager = manager.clone(); + let delayed_session_id = session.session_id.clone(); + let delayed_write = tokio::spawn(async move { + delayed_manager + .persist_current_context_usage( + &delayed_session_id, + SessionContextUsage { + turn_id: "turn-1".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }, + ) + .await + }); + tokio::task::yield_now().await; + assert!(!delayed_write.is_finished()); + + manager + .sessions + .get_mut(&session.session_id) + .expect("session should remain active") + .dialog_turn_ids + .clear(); + drop(mutation_guard); + delayed_write + .await + .expect("delayed write should join") + .expect("delayed write should be ignored"); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert!(metadata.current_context_usage.is_none()); + } + #[tokio::test] async fn execution_binding_rejects_a_session_after_its_first_turn() { let manager = in_memory_test_manager(); diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index 71ae188663..8a34433d1c 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -74,12 +74,6 @@ pub async fn init_agentic_system_for_profile_with_runtime_ownership( let path_manager = try_get_path_manager_arc()?; let persistence_manager = Arc::new(persistence::PersistenceManager::new(path_manager.clone())?); let token_usage_service = Arc::new(TokenUsageService::new(path_manager.clone()).await?); - let token_usage_subscriber = Arc::new(TokenUsageSubscriber::new(token_usage_service.clone())); - event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); - event_router.subscribe_internal( - "thread_goal_tokens".to_string(), - Arc::new(ThreadGoalTokenSubscriber), - ); let context_store = Arc::new(session::SessionContextStore::new()); let context_compressor = Arc::new(session::ContextCompressor::new(Default::default())); @@ -90,6 +84,21 @@ pub async fn init_agentic_system_for_profile_with_runtime_ownership( Default::default(), )); + event_router.subscribe_internal( + "token_usage".to_string(), + Arc::new(TokenUsageSubscriber::new(token_usage_service.clone())), + ); + event_router.subscribe_internal( + "session_context_usage".to_string(), + Arc::new(session::SessionContextUsageSubscriber::new( + session_manager.clone(), + )), + ); + event_router.subscribe_internal( + "thread_goal_tokens".to_string(), + Arc::new(ThreadGoalTokenSubscriber), + ); + let tool_registry = tools::registry::get_global_tool_registry(); let tool_state_manager = Arc::new(tools::pipeline::ToolStateManager::new(event_queue.clone())); let permission_request_manager = diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index a051578a78..3deeed8e27 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -398,6 +398,18 @@ pub fn build_branched_session_metadata(facts: BranchSessionMetadataFacts<'_>) -> facts.boundary, facts.branch_lineage, ); + if metadata + .current_context_usage + .as_ref() + .is_some_and(|usage| { + !facts + .branched_turns + .iter() + .any(|turn| turn.turn_id == usage.turn_id) + }) + { + metadata.current_context_usage = None; + } metadata.relationship = None; metadata.todos = None; metadata.review_action_state = None; @@ -480,8 +492,9 @@ fn normalize_nonempty(value: &str) -> Option { mod tests { use super::*; use crate::session::{ - ModelRoundData, SessionMetadata, SessionRelationship, SessionRelationshipKind, - TextItemData, ToolCallData, ToolItemData, UserMessageData, + ModelRoundData, SessionContextUsage, SessionContextUsageSource, SessionMetadata, + SessionRelationship, SessionRelationshipKind, TextItemData, ToolCallData, ToolItemData, + UserMessageData, }; use serde_json::json; @@ -768,6 +781,14 @@ mod tests { "parentDialogTurnId": "legacy-turn", "preserved": "value" })); + source.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-3".to_string(), + input_tokens: 50_000, + output_tokens: Some(500), + total_tokens: 50_500, + timestamp: 40, + source: SessionContextUsageSource::ModelRequest, + }); source.relationship = Some(SessionRelationship { kind: Some(SessionRelationshipKind::Subagent), parent_session_id: Some("parent".to_string()), @@ -818,6 +839,7 @@ mod tests { assert!(branched.deep_review_run_manifest.is_none()); assert!(branched.unread_completion.is_none()); assert!(branched.needs_user_attention.is_none()); + assert!(branched.current_context_usage.is_none()); let custom_metadata = branched .custom_metadata @@ -835,6 +857,40 @@ mod tests { ); } + #[test] + fn build_branched_session_metadata_keeps_usage_for_a_copied_turn() { + let mut source = metadata("source"); + source.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-2".to_string(), + input_tokens: 2_000, + output_tokens: Some(200), + total_tokens: 2_200, + timestamp: 40, + source: SessionContextUsageSource::ModelRequest, + }); + let turns = vec![turn("target", "turn-1", 0), turn("target", "turn-2", 1)]; + let branch_lineage = BranchSessionLineage { + base_session_name: "Source".to_string(), + ordinal: 1, + }; + + let branched = build_branched_session_metadata(BranchSessionMetadataFacts { + source_metadata: &source, + target_session_id: "target".to_string(), + target_session_name: "Target".to_string(), + target_agent_type: "agentic".to_string(), + source_session_id: "source", + source_turn_id: "turn-2", + source_turn_index: 1, + boundary: SessionBranchBoundary::ThroughTurn, + branched_turns: &turns, + branch_lineage: &branch_lineage, + now_ms: 42, + }); + + assert_eq!(branched.current_context_usage, source.current_context_usage); + } + #[test] fn branch_lineage_uses_the_inherited_title_namespace_for_renamed_suffixes() { let mut root = metadata("root"); diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index e7b9f6bbe9..69bfa7ea1c 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -71,6 +71,7 @@ pub fn build_session_metadata(facts: SessionMetadataBuildFacts<'_>) -> SessionMe .or_else(|| existing.and_then(|value| value.snapshot_session_id.clone())), tags: existing.map(|value| value.tags.clone()).unwrap_or_default(), custom_metadata: existing.and_then(|value| value.custom_metadata.clone()), + current_context_usage: existing.and_then(|value| value.current_context_usage.clone()), relationship: build_session_relationship(facts.session_kind, existing), todos: existing.and_then(|value| value.todos.clone()), review_action_state: existing.and_then(|value| value.review_action_state.clone()), @@ -227,6 +228,13 @@ pub fn refresh_session_metadata_from_turns( metadata.message_count = turns.iter().map(estimate_turn_message_count).sum(); metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); metadata.last_finished_at = turns.iter().filter_map(dialog_turn_finished_at).max(); + if metadata + .current_context_usage + .as_ref() + .is_some_and(|usage| !turns.iter().any(|turn| turn.turn_id == usage.turn_id)) + { + metadata.current_context_usage = None; + } metadata.last_active_at = last_active_at; fill_workspace_path_if_missing(metadata, workspace_path); } diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 0d4029e001..7da2e98399 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -182,6 +182,15 @@ pub struct SessionMetadata { #[serde(skip_serializing_if = "Option::is_none", alias = "custom_metadata")] pub custom_metadata: Option, + /// Latest authoritative context-usage value for restoring context display + /// state across hosts and process restarts. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "current_context_usage" + )] + pub current_context_usage: Option, + /// Structured child-session relationship metadata. #[serde( default, @@ -551,6 +560,44 @@ pub struct DialogTurnTokenUsageData { pub timestamp: u64, } +/// Source of a persisted session context-usage value. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionContextUsageSource { + ModelRequest, + ContextCompression, +} + +/// Exact context-usage value owned by the Agent Session runtime. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionContextUsage { + /// Dialog turn that produced this value. + #[serde(alias = "turn_id")] + pub turn_id: String, + + /// Input/prompt tokens for a model request, or the compacted context size. + #[serde(alias = "input_tokens")] + pub input_tokens: u64, + + /// Output/completion tokens when this value came from a model request. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "output_tokens" + )] + pub output_tokens: Option, + + /// Provider total for a model request, or the compacted context size. + #[serde(alias = "total_tokens")] + pub total_tokens: u64, + + /// Runtime event timestamp in milliseconds since epoch. + pub timestamp: u64, + + pub source: SessionContextUsageSource, +} + /// Persisted dialog turn kind. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -996,6 +1043,7 @@ impl SessionMetadata { snapshot_session_id: None, tags: Vec::new(), custom_metadata: None, + current_context_usage: None, relationship: None, todos: None, review_action_state: None, diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_metadata_contracts.rs index e633495c92..629a831678 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_metadata_contracts.rs @@ -3,8 +3,9 @@ use bitfun_services_core::session::{ build_session_index_snapshot, refresh_session_metadata_from_turns, remove_session_index_entry, try_refresh_session_metadata_for_saved_turn, upsert_session_index_entry, DialogTurnData, - DialogTurnKind, ModelRoundData, SessionKind, SessionMetadata, StoredSessionIndexFile, - TextItemData, ToolCallData, ToolItemData, TurnStatus, UserMessageData, + DialogTurnKind, ModelRoundData, SessionContextUsage, SessionContextUsageSource, SessionKind, + SessionMetadata, StoredSessionIndexFile, TextItemData, ToolCallData, ToolItemData, TurnStatus, + UserMessageData, }; fn metadata(session_id: &str) -> SessionMetadata { @@ -172,6 +173,73 @@ fn full_refresh_recomputes_metadata_counters_from_turns() { ); } +#[test] +fn session_context_usage_round_trips_as_top_level_metadata() { + let mut metadata = metadata("session-1"); + metadata.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-3".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }); + + let value = serde_json::to_value(&metadata).expect("serialize metadata"); + assert_eq!(value["currentContextUsage"]["turnId"], "turn-3"); + assert_eq!(value["currentContextUsage"]["source"], "model_request"); + + let restored: SessionMetadata = serde_json::from_value(value).expect("deserialize metadata"); + assert_eq!( + restored.current_context_usage, + metadata.current_context_usage + ); +} + +#[test] +fn full_refresh_drops_context_usage_for_a_removed_turn() { + let mut metadata = metadata("session-1"); + metadata.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-1".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }); + + refresh_session_metadata_from_turns( + &mut metadata, + "D:/workspace/project", + &[turn("session-1", 0, 1, 0)], + 42, + ); + + assert!(metadata.current_context_usage.is_none()); +} + +#[test] +fn full_refresh_keeps_context_usage_for_a_surviving_turn() { + let mut metadata = metadata("session-1"); + metadata.current_context_usage = Some(SessionContextUsage { + turn_id: "turn-0".to_string(), + input_tokens: 42_000, + output_tokens: Some(1_500), + total_tokens: 43_500, + timestamp: 123, + source: SessionContextUsageSource::ModelRequest, + }); + + refresh_session_metadata_from_turns( + &mut metadata, + "D:/workspace/project", + &[turn("session-1", 0, 1, 0)], + 42, + ); + + assert!(metadata.current_context_usage.is_some()); +} + #[test] fn saved_turn_refresh_updates_incrementally_for_append_and_replace() { let mut metadata = metadata("session-1"); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index ecef4c4570..5462ae2c1f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -15,7 +15,7 @@ import type { DialogTurn, FlowToolItem, FlowUserSteeringItem, ModelRound, Sessio import type { FlowChatContext } from './types'; import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; -const { handleCompressionCompleted } = __test_only__; +const { handleCompressionCompleted, handleTokenUsageUpdate } = __test_only__; vi.mock('../../../shared/notification-system/services/NotificationService', () => ({ notificationService: { @@ -1166,6 +1166,8 @@ describe('handleCompressionCompleted', () => { outputTokens: undefined, totalTokens: 15_000, timestamp: expect.any(Number), + turnId: 'turn-1', + source: 'context_compression', }); }); @@ -1201,4 +1203,78 @@ describe('handleCompressionCompleted', () => { const session = FlowChatStore.getInstance().getState().sessions.get('session-1'); expect(session?.currentTokenUsage).toBeUndefined(); }); + + it('ignores a delayed successful compression after its source turn was removed', () => { + putFinishingSessionInStore(); + FlowChatStore.getInstance().deleteDialogTurn('session-1', 'turn-1'); + + handleCompressionCompleted(createFlowChatContext(), { + sessionId: 'session-1', + turnId: 'turn-1', + compressionId: 'compression-1', + applied: true, + tokensBefore: 90_000, + tokensAfter: 15_000, + }); + + const session = FlowChatStore.getInstance().getState().sessions.get('session-1'); + expect(session?.currentTokenUsage).toBeUndefined(); + }); +}); + +describe('handleTokenUsageUpdate', () => { + beforeEach(() => { + vi.restoreAllMocks(); + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('tracks the source turn on current usage without adding provenance to accumulated turn usage', () => { + putFinishingSessionInStore(); + + handleTokenUsageUpdate(createFlowChatContext(), { + sessionId: 'session-1', + turnId: 'turn-1', + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + }); + + const session = FlowChatStore.getInstance().getState().sessions.get('session-1'); + expect(session?.currentTokenUsage).toMatchObject({ + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + turnId: 'turn-1', + source: 'model_request', + }); + expect(session?.dialogTurns[0].tokenUsage).toMatchObject({ + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + }); + expect(session?.dialogTurns[0].tokenUsage).not.toHaveProperty('turnId'); + expect(session?.dialogTurns[0].tokenUsage).not.toHaveProperty('source'); + }); + + it('ignores a delayed model usage update after its source turn was removed', () => { + putFinishingSessionInStore(); + FlowChatStore.getInstance().deleteDialogTurn('session-1', 'turn-1'); + + handleTokenUsageUpdate(createFlowChatContext(), { + sessionId: 'session-1', + turnId: 'turn-1', + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + }); + + const session = FlowChatStore.getInstance().getState().sessions.get('session-1'); + expect(session?.currentTokenUsage).toBeUndefined(); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 12e7ca676b..f39592b038 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -57,7 +57,6 @@ const pendingImageAnalysisTurns = new Map(); import { debouncedSaveDialogTurn, immediateSaveDialogTurn, - persistLastRequestTokenUsage, saveDialogTurnToDisk, cleanupSaveState, } from './PersistenceModule'; @@ -164,6 +163,7 @@ export const __test_only__ = { handleDialogTurnFailed, handleSubagentSessionLinked, handleModelRoundStart, + handleTokenUsageUpdate, handleCompressionCompleted, }; @@ -2127,6 +2127,13 @@ function handleTokenUsageUpdate(context: FlowChatContext, event: any): void { log.debug('Session not found (token usage update)', { sessionId }); return; } + if ( + typeof turnId !== 'string' + || !session.dialogTurns.some(turn => turn.id === turnId) + ) { + log.debug('Dropped token usage update for non-visible turn', { sessionId, turnId }); + return; + } if (typeof inputTokens !== 'number' || typeof totalTokens !== 'number') { log.debug('Dropped invalid token usage update', { event }); return; @@ -2135,20 +2142,11 @@ function handleTokenUsageUpdate(context: FlowChatContext, event: any): void { store.updateTokenUsage(sessionId, { inputTokens, outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined, - totalTokens + totalTokens, + turnId, + source: 'model_request', }, turnId); - // Persist the exact last request usage so the context display survives a - // restart. Skip ACP sessions: their display is driven by - // currentAcpContextUsage instead. - if (!session.mode?.startsWith('acp:') && !session.config.agentType?.startsWith('acp:')) { - persistLastRequestTokenUsage(context, sessionId, { - inputTokens, - outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined, - totalTokens, - }); - } - if (maxContextTokens !== undefined && maxContextTokens !== null) { store.updateSessionMaxContextTokens(sessionId, maxContextTokens); } @@ -2264,6 +2262,14 @@ function handleCompressionCompleted(context: FlowChatContext, event: any): void }); const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + if ( + typeof turnId !== 'string' + || !session?.dialogTurns.some(turn => turn.id === turnId) + ) { + log.debug('Dropped compression completion for non-visible turn', { sessionId, turnId }); + return; + } store.updateModelRoundItem(sessionId, turnId, compressionId, { toolResult: { @@ -2294,6 +2300,8 @@ function handleCompressionCompleted(context: FlowChatContext, event: any): void inputTokens: tokensAfter, totalTokens: tokensAfter, outputTokens: undefined, + turnId, + source: 'context_compression', }); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index daac1d78e4..fc6bffd29e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -604,83 +604,3 @@ export async function touchSessionActivity( log.debug('Failed to touch session activity', { sessionId, error }); } } - -const lastRequestTokenUsageDebouncers = new Map< - string, - ReturnType ->(); - -/** - * Persist the exact last model request usage into session metadata so the - * input-box context display can be restored after an app restart. - * - * Only the session-level last request value is stored; the dialog turn usage - * stays accumulated per turn and must not be reused as a single-request - * approximation. The write is trailing-throttled because agentic sessions - * can emit one TokenUsageUpdated per model round. - */ -export function persistLastRequestTokenUsage( - context: FlowChatContext, - sessionId: string, - usage: { inputTokens: number; outputTokens?: number; totalTokens: number }, -): void { - const existingTimer = lastRequestTokenUsageDebouncers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - const timer = setTimeout(() => { - lastRequestTokenUsageDebouncers.delete(sessionId); - void persistLastRequestTokenUsageNow(context, sessionId, usage).catch(error => { - log.warn('Failed to persist last request token usage', { sessionId, error }); - }); - }, COALESCED_IMMEDIATE_SAVE_DELAY_MS); - lastRequestTokenUsageDebouncers.set(sessionId, timer); -} - -async function persistLastRequestTokenUsageNow( - context: FlowChatContext, - sessionId: string, - usage: { inputTokens: number; outputTokens?: number; totalTokens: number }, -): Promise { - const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); - - const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) return; - if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; - - const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); - - let existingMetadata: any = null; - try { - existingMetadata = await sessionAPI.loadSessionMetadata( - sessionId, - workspacePath, - session.remoteConnectionId, - session.remoteSshHost - ); - } catch { - // Metadata may not exist yet for a fresh session; the patch below still works. - } - - const metadata = { - ...existingMetadata, - sessionId, - customMetadata: { - ...(existingMetadata?.customMetadata ?? {}), - lastRequestTokenUsage: { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - totalTokens: usage.totalTokens, - timestamp: Date.now(), - }, - }, - }; - - await sessionAPI.saveSessionMetadata( - metadata, - workspacePath, - ['titleMetadata'], - session.remoteConnectionId, - session.remoteSshHost - ); -} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 1e45a2ff51..e75c0e6eaa 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -683,6 +683,140 @@ describe('FlowChatStore token usage', () => { totalTokens: 425, }); }); + + it('falls back safely when deleting the turn that sourced current usage', () => { + const previousTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { id: 'user-1', content: 'first', timestamp: 1_000 }, + modelRounds: [{ id: 'round-1' }], + tokenUsage: { + inputTokens: 600, + outputTokens: 100, + totalTokens: 700, + timestamp: 1_500, + }, + status: 'completed' as const, + startTime: 1_000, + }; + const sourceTurn = { + id: 'turn-2', + sessionId: 'session-1', + userMessage: { id: 'user-2', content: 'second', timestamp: 2_000 }, + modelRounds: [{ id: 'round-2' }], + status: 'completed' as const, + startTime: 2_000, + }; + const session = createSession({ + dialogTurns: [previousTurn, sourceTurn], + currentTokenUsage: { + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + timestamp: 2_500, + turnId: 'turn-2', + source: 'model_request', + }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.deleteDialogTurn(session.sessionId, 'turn-2'); + + expect(flowChatStore.getState().sessions.get(session.sessionId)?.currentTokenUsage).toEqual({ + ...previousTurn.tokenUsage, + turnId: 'turn-1', + }); + }); + + it('does not derive a stale fallback from partial history after deleting the usage source', () => { + const previousTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { id: 'user-1', content: 'partial older turn', timestamp: 1_000 }, + modelRounds: [{ id: 'round-1' }], + tokenUsage: { + inputTokens: 600, + outputTokens: 100, + totalTokens: 700, + timestamp: 1_500, + }, + status: 'completed' as const, + startTime: 1_000, + }; + const sourceTurn = { + id: 'turn-2', + sessionId: 'session-1', + userMessage: { id: 'user-2', content: 'source', timestamp: 2_000 }, + modelRounds: [{ id: 'round-2' }], + status: 'completed' as const, + startTime: 2_000, + }; + const session = createSession({ + dialogTurns: [previousTurn, sourceTurn], + isPartial: true, + currentTokenUsage: { + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + timestamp: 2_500, + turnId: 'turn-2', + source: 'model_request', + }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.deleteDialogTurn(session.sessionId, 'turn-2'); + + expect( + flowChatStore.getState().sessions.get(session.sessionId)?.currentTokenUsage, + ).toBeUndefined(); + }); + + it('clears usage when truncation removes its source and no safe fallback exists', () => { + const retainedTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { id: 'user-1', content: 'first', timestamp: 1_000 }, + modelRounds: [], + status: 'completed' as const, + startTime: 1_000, + }; + const sourceTurn = { + id: 'turn-2', + sessionId: 'session-1', + userMessage: { id: 'user-2', content: 'second', timestamp: 2_000 }, + modelRounds: [{ id: 'round-2' }], + status: 'completed' as const, + startTime: 2_000, + }; + const session = createSession({ + dialogTurns: [retainedTurn, sourceTurn], + currentTokenUsage: { + inputTokens: 1_200, + outputTokens: 320, + totalTokens: 1_520, + timestamp: 2_500, + turnId: 'turn-2', + source: 'model_request', + }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.truncateDialogTurnsFrom(session.sessionId, 1); + + expect( + flowChatStore.getState().sessions.get(session.sessionId)?.currentTokenUsage, + ).toBeUndefined(); + }); }); describe('FlowChatStore round attempts', () => { @@ -1847,6 +1981,224 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('clears Peer usage whose source turn is absent even when the running snapshot is unchanged', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Processing { current_turn_id: "turn-live", phase: Streaming }', + turnCount: 1, + createdAt: 1, + }, + turns: [{ + turnId: 'turn-live', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-live', content: 'continue', timestamp: 1 }, + modelRounds: [{ + id: 'round-live', + turnId: 'turn-live', + roundIndex: 0, + timestamp: 1, + textItems: [{ + id: 'host-text-id', + content: 'partial answer', + isStreaming: true, + timestamp: 2, + status: 'streaming', + }], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'streaming', + }], + startTime: 1, + status: 'inprogress', + }], + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 1, + totalTurnCount: 1, + }); + const localTurn = { + id: 'turn-live', + sessionId: 'history-1', + userMessage: { id: 'user-live', content: 'continue', timestamp: 1 }, + modelRounds: [{ + id: 'round-live', + index: 0, + items: [{ + id: 'controller-text-id', + type: 'text' as const, + content: 'partial answer plus live data', + isStreaming: true, + isMarkdown: true, + timestamp: 3, + status: 'streaming' as const, + }], + isStreaming: true, + isComplete: false, + status: 'streaming' as const, + startTime: 1, + }], + status: 'processing' as const, + startTime: 1, + backendTurnIndex: 0, + }; + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + historyState: 'ready', + dialogTurns: [localTurn], + currentTokenUsage: { + inputTokens: 42_000, + outputTokens: 1_000, + totalTokens: 43_000, + timestamp: 3, + turnId: 'turn-no-longer-visible', + source: 'model_request', + }, + })], + ]), + activeSessionId: 'history-1', + })); + + const result = await flowChatStore.refreshPeerSessionSnapshot( + 'history-1', + '/Users/host/project', + { replaceRunningSnapshot: false }, + ); + + expect(result.applied).toBe(true); + const refreshedSession = flowChatStore.getState().sessions.get('history-1'); + expect(refreshedSession?.currentTokenUsage).toBeUndefined(); + expect(refreshedSession?.dialogTurns[0].modelRounds[0].items[0]).toMatchObject({ + id: 'controller-text-id', + content: 'partial answer plus live data', + }); + }); + + it('replaces stale local usage with authoritative Peer usage for a multi-round turn', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [{ + turnId: 'turn-live', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-live', content: 'continue', timestamp: 1 }, + modelRounds: [ + { + id: 'round-1', + turnId: 'turn-live', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }, + { + id: 'round-2', + turnId: 'turn-live', + roundIndex: 1, + timestamp: 2, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 2, + status: 'completed', + }, + ], + tokenUsage: { + inputTokens: 90_000, + outputTokens: 2_000, + totalTokens: 92_000, + timestamp: 3, + }, + startTime: 1, + endTime: 3, + status: 'completed', + }], + currentContextUsage: { + inputTokens: 42_000, + outputTokens: 1_500, + totalTokens: 43_500, + timestamp: 4, + turnId: 'turn-live', + source: 'model_request', + }, + contextRestoreState: 'ready', + isPartial: false, + loadedTurnCount: 1, + totalTurnCount: 1, + }); + const localTurn = { + id: 'turn-live', + sessionId: 'history-1', + userMessage: { id: 'user-live', content: 'continue', timestamp: 1 }, + modelRounds: [{ + id: 'round-1', + index: 0, + items: [], + isStreaming: false, + isComplete: true, + status: 'completed' as const, + startTime: 1, + }], + status: 'completed' as const, + startTime: 1, + backendTurnIndex: 0, + }; + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + historyState: 'ready', + dialogTurns: [localTurn], + currentTokenUsage: { + inputTokens: 12_000, + outputTokens: 500, + totalTokens: 12_500, + timestamp: 2, + turnId: 'turn-live', + source: 'model_request', + }, + })], + ]), + activeSessionId: 'history-1', + })); + + const result = await flowChatStore.refreshPeerSessionSnapshot( + 'history-1', + '/Users/host/project', + { replaceRunningSnapshot: false }, + ); + + expect(result.applied).toBe(true); + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toEqual({ + inputTokens: 42_000, + outputTokens: 1_500, + totalTokens: 43_500, + timestamp: 4, + turnId: 'turn-live', + source: 'model_request', + }); + }); + it('replaces a stale running projection after the Peer Host has completed', async () => { peerModeFlagMock.active = true; apiMocks.restoreSessionView.mockResolvedValueOnce({ @@ -5305,6 +5657,147 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('does not backfill through the latest terminal turn when its usage spans multiple rounds', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [ + { + ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], + endTime: 2, + tokenUsage: { + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + timestamp: 2, + }, + }, + { + ...createPersistedTurn(1), + modelRounds: [ + { + id: 'round-1', + turnId: 'turn-1', + roundIndex: 0, + timestamp: 3, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 3, + status: 'completed', + }, + { + id: 'round-2', + turnId: 'turn-1', + roundIndex: 1, + timestamp: 4, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 4, + status: 'completed', + }, + ], + endTime: 5, + tokenUsage: { + inputTokens: 8_900_000, + outputTokens: 300, + totalTokens: 8_900_300, + timestamp: 5, + }, + }, + ], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toBeUndefined(); + }); + + it('uses the restored agent type to suppress native usage for ACP hydration', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'acp:test', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [{ + ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], + endTime: 2, + tokenUsage: { + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + timestamp: 2, + }, + }], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + mode: 'agentic', + config: { agentType: 'agentic' }, + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + mode: 'acp:test', + currentTokenUsage: undefined, + }); + }); + it('keeps an existing currentTokenUsage when hydrating historical turns', async () => { peerModeFlagMock.active = true; apiMocks.restoreSessionView.mockResolvedValueOnce({ @@ -5352,6 +5845,8 @@ describe('FlowChatStore historical session hydration state', () => { outputTokens: 1, totalTokens: 1000, timestamp: 5, + turnId: 'turn-0', + source: 'model_request', }, })], ]), @@ -5364,10 +5859,138 @@ describe('FlowChatStore historical session hydration state', () => { inputTokens: 999, outputTokens: 1, totalTokens: 1000, + turnId: 'turn-0', + source: 'model_request', }); }); - it('restores the exact last request token usage from persisted metadata', async () => { + it('discards stale exact usage but keeps a safe fallback when restore reports no exact usage', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [{ + ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], + endTime: 2, + tokenUsage: { + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + timestamp: 2, + }, + }], + currentContextUsage: null, + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + currentTokenUsage: { + inputTokens: 999, + outputTokens: 1, + totalTokens: 1000, + timestamp: 5, + turnId: 'turn-0', + source: 'model_request', + }, + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toMatchObject({ + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + turnId: 'turn-0', + }); + }); + + it('invalidates restored context usage when its source turn is not visible after hydration', async () => { + peerModeFlagMock.active = true; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [{ + ...createPersistedTurn(0), + modelRounds: [{ + id: 'round-0', + turnId: 'turn-0', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [], + thinkingItems: [], + startTime: 1, + status: 'completed', + }], + endTime: 2, + tokenUsage: { + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + timestamp: 2, + }, + }], + contextRestoreState: 'ready', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + currentTokenUsage: { + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + timestamp: 5, + turnId: 'deleted-turn', + source: 'model_request', + }, + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toMatchObject({ + inputTokens: 2400, + outputTokens: 300, + totalTokens: 2700, + }); + }); + + it('restores current token usage from top-level persisted context metadata', async () => { apiMocks.listSessions.mockResolvedValueOnce([ { sessionId: 'history-1', @@ -5376,13 +5999,13 @@ describe('FlowChatStore historical session hydration state', () => { modelName: 'auto', createdAt: 10, lastActiveAt: 20, - customMetadata: { - lastRequestTokenUsage: { - inputTokens: 42000, - outputTokens: 1500, - totalTokens: 43500, - timestamp: 21, - }, + currentContextUsage: { + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + timestamp: 21, + turnId: 'turn-7', + source: 'model_request', }, }, ]); @@ -5393,10 +6016,37 @@ describe('FlowChatStore historical session hydration state', () => { inputTokens: 42000, outputTokens: 1500, totalTokens: 43500, + turnId: 'turn-7', + source: 'model_request', }); }); - it('ignores invalid persisted last request token usage', async () => { + it('does not restore native context metadata for ACP sessions', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved ACP session', + agentType: 'acp:test', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + currentContextUsage: { + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + timestamp: 21, + turnId: 'turn-7', + source: 'model_request', + }, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toBeUndefined(); + }); + + it('ignores invalid persisted current context usage', async () => { apiMocks.listSessions.mockResolvedValueOnce([ { sessionId: 'history-1', @@ -5405,13 +6055,38 @@ describe('FlowChatStore historical session hydration state', () => { modelName: 'auto', createdAt: 10, lastActiveAt: 20, - customMetadata: { - lastRequestTokenUsage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - timestamp: 21, - }, + currentContextUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + timestamp: 21, + turnId: 'turn-7', + source: 'model_request', + }, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun'); + + expect(flowChatStore.getState().sessions.get('history-1')?.currentTokenUsage).toBeUndefined(); + }); + + it('ignores persisted context usage without valid provenance', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + currentContextUsage: { + inputTokens: 42000, + outputTokens: 1500, + totalTokens: 43500, + timestamp: 21, + turnId: ' ', + source: 'unknown_source', }, }, ]); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index bf3f9108b8..05be069b2d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -39,6 +39,7 @@ import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import type { DialogTurnData, LocalCommandMetadata, + SessionContextUsage, SessionKind, SessionTurnCatalog, } from '@/shared/types/session-history'; @@ -57,6 +58,7 @@ import { import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; import { deriveContextUsageFromTurns } from '../utils/tokenUsageDisplay'; +import { isAcpAgentType } from '../utils/acpSession'; import { deriveSessionTitleState, deriveSessionTitleStateFromMetadata, @@ -107,11 +109,10 @@ function firstNonEmptyString(...values: unknown[]): string | undefined { return undefined; } -function isAcpSessionForContextUsage(session: Session): boolean { - return Boolean( - session.mode?.startsWith('acp:') - || session.config.agentType?.startsWith('acp:'), - ); +function persistedCurrentContextUsageValue( + metadata: { currentContextUsage?: SessionContextUsage }, +): unknown { + return metadata.currentContextUsage; } function deriveRestoredCurrentTokenUsage(value: unknown): TokenUsage | undefined { @@ -124,14 +125,98 @@ function deriveRestoredCurrentTokenUsage(value: unknown): TokenUsage | undefined return undefined; } const totalTokens = record.totalTokens; + const turnId = typeof record.turnId === 'string' && record.turnId.trim() + ? record.turnId.trim() + : undefined; + const source = record.source === 'model_request' || record.source === 'context_compression' + ? record.source + : undefined; + const outputTokens = record.outputTokens; + const timestamp = record.timestamp; + if ( + !turnId + || !source + || typeof totalTokens !== 'number' + || !Number.isFinite(totalTokens) + || totalTokens < 0 + || ( + outputTokens !== undefined + && ( + typeof outputTokens !== 'number' + || !Number.isFinite(outputTokens) + || outputTokens < 0 + ) + ) + || typeof timestamp !== 'number' + || !Number.isFinite(timestamp) + ) { + return undefined; + } return { inputTokens, - outputTokens: typeof record.outputTokens === 'number' ? record.outputTokens : undefined, - totalTokens: typeof totalTokens === 'number' && Number.isFinite(totalTokens) ? totalTokens : inputTokens, - timestamp: typeof record.timestamp === 'number' ? record.timestamp : Date.now(), + outputTokens, + totalTokens, + timestamp, + turnId, + source, }; } +function reconcileHydratedCurrentTokenUsage( + currentTokenUsage: TokenUsage | undefined, + dialogTurns: DialogTurn[], + restoredAgentType: string | undefined, + sourceVisibilityTurns: DialogTurn[] = dialogTurns, +): TokenUsage | undefined { + if (isAcpAgentType(restoredAgentType)) { + return undefined; + } + + const sourceTurnId = currentTokenUsage?.turnId; + const retainedUsage = sourceTurnId + && !sourceVisibilityTurns.some(turn => turn.id === sourceTurnId) + ? undefined + : currentTokenUsage; + + return retainedUsage ?? deriveContextUsageFromTurns(dialogTurns); +} + +function reconcileRestoreViewCurrentTokenUsage( + currentTokenUsage: TokenUsage | undefined, + authoritativeUsage: SessionContextUsage | null | undefined, + dialogTurns: DialogTurn[], + restoredAgentType: string | undefined, + sourceVisibilityTurns: DialogTurn[] = dialogTurns, +): TokenUsage | undefined { + if (isAcpAgentType(restoredAgentType)) { + return undefined; + } + const candidateUsage = authoritativeUsage === undefined + ? currentTokenUsage + : authoritativeUsage === null + ? undefined + : deriveRestoredCurrentTokenUsage(authoritativeUsage); + return reconcileHydratedCurrentTokenUsage( + candidateUsage, + dialogTurns, + restoredAgentType, + sourceVisibilityTurns, + ); +} + +function currentTokenUsageAfterSourceRemoval( + session: Pick, + dialogTurns: DialogTurn[], + sourceRemoved: boolean, +): TokenUsage | undefined { + if (!sourceRemoved) { + return session.currentTokenUsage; + } + return session.isPartial === true + ? undefined + : deriveContextUsageFromTurns(dialogTurns); +} + function persistedSessionRemoteScope( metadata: { remoteConnectionId?: unknown; @@ -4968,10 +5053,16 @@ export class FlowChatStore { && !isProvisionalUsageReportTurn(deletedTurn); shiftLaterOrdinals = countedOptimisticTurn; const nextCanonicalTurnCount = canonicalSessionTurns({ dialogTurns: updatedDialogTurns }).length; + const currentTokenUsage = currentTokenUsageAfterSourceRemoval( + session, + updatedDialogTurns, + session.currentTokenUsage?.turnId === dialogTurnId, + ); const updatedSession = { ...session, dialogTurns: updatedDialogTurns, + currentTokenUsage, loadedTurnCount: nextCanonicalTurnCount, totalTurnCount: countedOptimisticTurn ? Math.max(nextCanonicalTurnCount, projectedSessionTurnCount(session) - 1) @@ -5082,6 +5173,12 @@ export class FlowChatStore { const clampedIndex = Math.max(0, Math.min(turnIndex, session.dialogTurns.length)); const updatedDialogTurns = session.dialogTurns.slice(0, clampedIndex); + const removedCurrentUsageSource = Boolean( + session.currentTokenUsage?.turnId + && session.dialogTurns.slice(clampedIndex).some( + turn => turn.id === session.currentTokenUsage?.turnId, + ), + ); const hasCompleteHistory = session.isPartial !== true; const historyView = this.sessionHistoryViews.get(sessionId); const currentCatalog = session.turnCatalog?.sessionId === sessionId @@ -5099,6 +5196,11 @@ export class FlowChatStore { const updatedSession = { ...session, dialogTurns: updatedDialogTurns, + currentTokenUsage: currentTokenUsageAfterSourceRemoval( + session, + updatedDialogTurns, + removedCurrentUsageSource, + ), ...(hasCompleteHistory ? { loadedTurnCount: updatedDialogTurns.length, totalTurnCount: updatedDialogTurns.length, @@ -5609,18 +5711,23 @@ export class FlowChatStore { public updateTokenUsage( sessionId: string, - tokenUsage: { inputTokens: number; outputTokens?: number; totalTokens: number }, + tokenUsage: Pick< + TokenUsage, + 'inputTokens' | 'outputTokens' | 'totalTokens' | 'turnId' | 'source' + >, dialogTurnId?: string ): void { this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; - const nextTokenUsage = { + const nextTokenUsage: TokenUsage = { inputTokens: tokenUsage.inputTokens, outputTokens: tokenUsage.outputTokens, totalTokens: tokenUsage.totalTokens, - timestamp: Date.now() + timestamp: Date.now(), + ...(tokenUsage.turnId ? { turnId: tokenUsage.turnId } : {}), + ...(tokenUsage.source ? { source: tokenUsage.source } : {}), }; let dialogTurns = session.dialogTurns; if (dialogTurnId) { @@ -6269,9 +6376,7 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, ); - const restoredCurrentTokenUsage = deriveRestoredCurrentTokenUsage( - metadata.customMetadata?.lastRequestTokenUsage, - ); + const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); this.setState(prev => { if (surfaceGeneration !== this.surfaceGeneration) { @@ -6283,6 +6388,9 @@ export class FlowChatStore { const rawAgentType = metadata.agentType || 'agentic'; const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; + const restoredCurrentTokenUsage = isAcpAgentType(validatedAgentType) + ? undefined + : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); if (rawAgentType !== validatedAgentType) { log.warn('Invalid agentType, falling back to agentic', { sessionId: metadata.sessionId, rawAgentType, validatedAgentType }); @@ -6651,9 +6759,7 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, ); - const restoredCurrentTokenUsage = deriveRestoredCurrentTokenUsage( - metadata.customMetadata?.lastRequestTokenUsage, - ); + const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); this.setState(prev => { if (prev.sessions.has(metadata.sessionId)) { @@ -6662,6 +6768,9 @@ export class FlowChatStore { const rawAgentType = metadata.agentType || 'agentic'; const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; + const restoredCurrentTokenUsage = isAcpAgentType(validatedAgentType) + ? undefined + : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); if (rawAgentType !== validatedAgentType) { log.warn('Invalid agentType, falling back to agentic', { sessionId: metadata.sessionId, rawAgentType, validatedAgentType }); @@ -6872,14 +6981,28 @@ export class FlowChatStore { } } + mergedTurns.sort(compareDialogTurnOrder); const turnCatalog = restored.turnCatalog?.sessionId === sessionId ? selectPreferredTurnCatalog(session.turnCatalog, restored.turnCatalog) : session.turnCatalog; - if (!turnsChanged && turnCatalog === session.turnCatalog) { + const restoredAgentType = + restored.session.agentType || session.mode || session.config.agentType; + const currentTokenUsage = reconcileRestoreViewCurrentTokenUsage( + session.currentTokenUsage, + restored.currentContextUsage, + mergedTurns, + restoredAgentType, + snapshotTurns, + ); + if ( + !turnsChanged + && turnCatalog === session.turnCatalog + && restoredAgentType === session.mode + && currentTokenUsage === session.currentTokenUsage + ) { return prev; } - mergedTurns.sort(compareDialogTurnOrder); const newSessions = new Map(prev.sessions); newSessions.set(sessionId, { ...session, @@ -6907,16 +7030,12 @@ export class FlowChatStore { ? { reasoningPreset: restored.session.reasoningPreset?.trim() || undefined } : {}), }, - mode: restored.session.agentType || session.mode, + mode: restoredAgentType, lastUserDialogMode: restored.session.lastUserDialogAgentType || session.lastUserDialogMode, lastSubmittedMode: restored.session.lastSubmittedAgentType ?? session.lastSubmittedMode, - currentTokenUsage: - session.currentTokenUsage - ?? (!isAcpSessionForContextUsage(session) - ? deriveContextUsageFromTurns(mergedTurns) - : undefined), + currentTokenUsage, }); applied = true; @@ -7047,6 +7166,7 @@ export class FlowChatStore { let restoredTotalTurnCount: number | undefined; let restoredTurnCatalog: SessionTurnCatalog | undefined; let restoredTiming: SessionViewRestoreTiming | undefined; + let restoredCurrentContextUsage: SessionContextUsage | null | undefined; // Finish or resume relay history import before Core restores its model // context. Ordinary local sessions return after one metadata read, while @@ -7187,6 +7307,7 @@ export class FlowChatStore { ? restored.turnCatalog : undefined; restoredTiming = restored.timings; + restoredCurrentContextUsage = restored.currentContextUsage; } catch (error) { if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { throw error; @@ -7345,6 +7466,9 @@ export class FlowChatStore { const session = prev.sessions.get(sessionId); if (!session) return prev; + const restoredAgentType = + restoredSessionInfo?.agentType || session.mode || session.config.agentType; + const updatedSession = { ...session, dialogTurns, @@ -7365,15 +7489,16 @@ export class FlowChatStore { ? { reasoningPreset: restoredSessionInfo.reasoningPreset?.trim() || undefined } : {}), }, - mode: restoredSessionInfo?.agentType || session.mode, + mode: restoredAgentType, lastUserDialogMode: restoredLastUserDialogMode, lastSubmittedMode: restoredSessionInfo?.lastSubmittedAgentType ?? session.lastSubmittedMode, - currentTokenUsage: - session.currentTokenUsage - ?? (!isAcpSessionForContextUsage(session) - ? deriveContextUsageFromTurns(dialogTurns) - : undefined), + currentTokenUsage: reconcileRestoreViewCurrentTokenUsage( + session.currentTokenUsage, + restoredCurrentContextUsage, + dialogTurns, + restoredAgentType, + ), }; const newSessions = new Map(prev.sessions); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 893bbbeae1..55ac8b56ec 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -6,6 +6,7 @@ import type { DialogTurnKind, SessionKind, + SessionContextUsageSource, SessionTitleSource, SessionTurnCatalog, } from '@/shared/types/session-history'; @@ -199,6 +200,10 @@ export interface TokenUsage { outputTokens?: number; totalTokens: number; timestamp: number; + /** Persisted source turn used to invalidate usage after history rewrites. */ + turnId?: string; + /** Runtime provenance for restored session-level context usage. */ + source?: SessionContextUsageSource; } export interface AcpContextUsage { diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts index 7c50c66f06..156b585bce 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.test.ts @@ -179,7 +179,10 @@ describe('deriveContextUsageFromTurns', () => { makeTurn({ id: 'turn-2', status: 'completed', tokenUsage: usage(2000) }), ]; - expect(deriveContextUsageFromTurns(turns)).toEqual(usage(2000)); + expect(deriveContextUsageFromTurns(turns)).toEqual({ + ...usage(2000), + turnId: 'turn-2', + }); }); it('skips unfinished turns and falls back to the last completed turn', () => { @@ -189,7 +192,10 @@ describe('deriveContextUsageFromTurns', () => { makeTurn({ id: 'turn-3', status: 'pending', tokenUsage: usage(300) }), ]; - expect(deriveContextUsageFromTurns(turns)).toEqual(usage(1000)); + expect(deriveContextUsageFromTurns(turns)).toEqual({ + ...usage(1000), + turnId: 'turn-1', + }); }); it('skips turns without usage and returns the last completed one that has it', () => { @@ -198,10 +204,13 @@ describe('deriveContextUsageFromTurns', () => { makeTurn({ id: 'turn-2', status: 'error', tokenUsage: usage(2500) }), ]; - expect(deriveContextUsageFromTurns(turns)).toEqual(usage(2500)); + expect(deriveContextUsageFromTurns(turns)).toEqual({ + ...usage(2500), + turnId: 'turn-2', + }); }); - it('skips completed turns with zero or invalid input tokens', () => { + it('uses the latest valid terminal usage even when an older turn is invalid', () => { const turns = [ makeTurn({ id: 'turn-1', @@ -215,10 +224,26 @@ describe('deriveContextUsageFromTurns', () => { }), ]; - expect(deriveContextUsageFromTurns(turns)).toMatchObject({ inputTokens: 420 }); + expect(deriveContextUsageFromTurns(turns)).toMatchObject({ + inputTokens: 420, + turnId: 'turn-2', + }); + }); + + it('does not scan past the latest terminal turn when its usage is invalid', () => { + const turns = [ + makeTurn({ id: 'turn-1', status: 'completed', tokenUsage: usage(1000) }), + makeTurn({ + id: 'turn-2', + status: 'completed', + tokenUsage: { inputTokens: 0, totalTokens: 0, timestamp: 3000 }, + }), + ]; + + expect(deriveContextUsageFromTurns(turns)).toBeUndefined(); }); - it('skips multi-round turns because accumulated usage would overestimate context', () => { + it('does not scan past the latest terminal turn when its multi-round usage is accumulated', () => { const turns = [ makeTurn({ id: 'turn-1', status: 'completed', tokenUsage: usage(1000) }), makeTurn({ @@ -229,7 +254,7 @@ describe('deriveContextUsageFromTurns', () => { }), ]; - expect(deriveContextUsageFromTurns(turns)).toEqual(usage(1000)); + expect(deriveContextUsageFromTurns(turns)).toBeUndefined(); }); it('returns undefined for empty input or when no completed single-round turn has usage', () => { diff --git a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts index 3f4b9961f2..ae2708486d 100644 --- a/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts +++ b/src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts @@ -36,14 +36,14 @@ function formatCompactNumber(value: number): string { } /** - * Derive the last completed single-round turn's token usage as a - * context-usage approximation. + * Derive the latest terminal turn-with-usage's token usage as a + * context-usage approximation when it represents exactly one model round. * * Used as a fallback to restore `session.currentTokenUsage` when a session is * hydrated from persisted history and no exact last-request usage was stored - * in session metadata. Only single-round turns are used: dialog turn usage - * accumulates across model rounds, so a multi-round turn's input total would - * badly overestimate the current context. + * in session metadata. Dialog turn usage accumulates across model rounds, so + * a multi-round latest turn is not usable. In that case we must not scan past + * it and mislabel an older request as the last request. */ export function deriveContextUsageFromTurns(turns: DialogTurn[] | undefined): TokenUsage | undefined { if (!turns) { @@ -53,22 +53,30 @@ export function deriveContextUsageFromTurns(turns: DialogTurn[] | undefined): To for (let i = turns.length - 1; i >= 0; i--) { const turn = turns[i]; const usage = turn.tokenUsage; - if (!usage) { + if ( + !usage + || ( + turn.status !== 'completed' + && turn.status !== 'error' + && turn.status !== 'cancelled' + ) + ) { continue; } + if ( - turn.status === 'completed' - || turn.status === 'error' - || turn.status === 'cancelled' + turn.modelRounds.length === 1 + && typeof usage.inputTokens === 'number' + && Number.isFinite(usage.inputTokens) + && usage.inputTokens > 0 ) { - if ( - turn.modelRounds.length === 1 - && typeof usage.inputTokens === 'number' - && usage.inputTokens > 0 - ) { - return usage; - } + return { + ...usage, + turnId: turn.id, + }; } + + return undefined; } return undefined; } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 921c5946a6..ce019e6662 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -5,6 +5,7 @@ import { createTauriCommandError } from '../errors/TauriCommandError'; import type { DialogTurnData, ModelRoundAttemptDiagnostic, + SessionContextUsage, SessionRelationship, SessionTurnCatalog, } from '@/shared/types/session-history'; @@ -241,6 +242,7 @@ export interface SessionViewRestoreTiming { export interface RestoreSessionViewResponse { session: SessionInfo; turns: DialogTurnData[]; + currentContextUsage?: SessionContextUsage | null; turnCatalog?: SessionTurnCatalog; contextRestoreState: 'ready' | 'pending'; isPartial?: boolean; diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index d865f61080..810492e95c 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -43,6 +43,18 @@ export interface SessionCustomMetadata extends Record { titleParams?: Record | null; } +export type SessionContextUsageSource = 'model_request' | 'context_compression'; + +/** Exact session-level context usage persisted by the Agent Session runtime. */ +export interface SessionContextUsage { + turnId: string; + inputTokens: number; + outputTokens?: number; + totalTokens: number; + timestamp: number; + source: SessionContextUsageSource; +} + export interface SessionMetadata { sessionId: string; sessionName: string; @@ -75,6 +87,7 @@ export interface SessionMetadata { snapshotSessionId?: string; tags: string[]; customMetadata?: SessionCustomMetadata; + currentContextUsage?: SessionContextUsage; relationship?: SessionRelationship; todos?: any[]; workspacePath?: string; From e640aa402023792a5fd6568f445ece1a81013085 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 6 Aug 2026 23:20:35 +0800 Subject: [PATCH 023/206] fix(desktop): persist early startup diagnostics - Capture Rust logs before Tauri logging initialization - Persist native startup trace events as flushed JSONL - Hand off atomically to the runtime logging backend - Expose early diagnostic file paths through runtime logging info --- src/apps/desktop/src/lib.rs | 25 +- src/apps/desktop/src/logging.rs | 249 +++++++++++++++++- src/apps/desktop/src/startup_trace.rs | 114 +++++++- .../src/infrastructure/config/types/index.ts | 2 + 4 files changed, 382 insertions(+), 8 deletions(-) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b05a671fd6..912032e0f7 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -431,13 +431,30 @@ pub async fn run() { .duration_since(UNIX_EPOCH) .map(|duration| format!("desktop-{}", duration.as_millis())) .unwrap_or_else(|_| "desktop-unknown".to_string()); - let startup_trace = DesktopStartupTrace::new(startup_trace_id.clone(), startup_started); - startup_trace.record_phase("native_process_start", "native"); let mut startup_timings = TimingCollector::default(); let in_debug = cfg!(debug_assertions) || std::env::var("DEBUG").unwrap_or_default() == "1"; let log_config = logging::LogConfig::new(in_debug); let log_targets = logging::build_log_targets(&log_config); let session_log_dir = log_config.session_log_dir.clone(); + if let Err(error) = logging::install_early_file_logging(&session_log_dir) { + eprintln!( + "Warning: Failed to install early startup logging: {}", + error + ); + } + let native_startup_trace_path = logging::native_startup_trace_path(&session_log_dir); + let startup_trace = match DesktopStartupTrace::new_persisted( + startup_trace_id.clone(), + startup_started, + &native_startup_trace_path, + ) { + Ok(trace) => trace, + Err(error) => { + log::warn!("Native startup trace persistence is unavailable: {}", error); + DesktopStartupTrace::new(startup_trace_id.clone(), startup_started) + } + }; + startup_trace.record_phase("native_process_start", "native"); crash_diagnostics::initialize_run_state(session_log_dir.clone(), &startup_trace_id); setup_panic_hook(); @@ -624,7 +641,8 @@ pub async fn run() { } let app = builder - .plugin(logging::build_log_plugin(log_targets)) + .plugin(logging::build_log_command_plugin()) + .plugin(logging::build_log_handoff_plugin(log_targets)) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -699,6 +717,7 @@ pub async fn run() { "register_runtime_log_state_and_crash_diagnostics", step_started, ); + startup_trace.record_logging_ready_and_stop_persistence(); // Ensure the Tauri NSIS registry install-location key points to the // actual install directory, so that auto-updates respect the custom diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 52360034cf..008cfeadf6 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -4,12 +4,12 @@ use bitfun_core::infrastructure::get_path_manager_arc; use chrono::Local; use serde::Serialize; use serde_json::Value; -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{ atomic::{AtomicU8, Ordering}, - Mutex, OnceLock, + Mutex, OnceLock, RwLock, }; use std::thread; use tauri::{plugin::TauriPlugin, Runtime}; @@ -23,11 +23,173 @@ const FLOW_CHAT_LOG_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; const FLOW_CHAT_LOG_MAX_BATCH_ENTRIES: usize = 256; const FLOW_CHAT_LOG_MAX_BATCH_BYTES: usize = 1024 * 1024; const FLOW_CHAT_LOG_MAX_ENTRY_BYTES: usize = 32 * 1024; +pub const EARLY_STARTUP_LOG_FILE_NAME: &str = "early-startup.log"; +pub const NATIVE_STARTUP_TRACE_FILE_NAME: &str = "native-startup-trace.jsonl"; static SESSION_LOG_DIR: OnceLock = OnceLock::new(); +static GLOBAL_LOG_ROUTER: OnceLock<&'static SwitchingLogger> = OnceLock::new(); // Default to Debug in early development for easier diagnostics static CURRENT_LOG_LEVEL: AtomicU8 = AtomicU8::new(level_filter_to_u8(log::LevelFilter::Debug)); static FLOW_CHAT_DIAGNOSTICS_WRITE_LOCK: Mutex<()> = Mutex::new(()); +struct EarlyFileLogger { + path: PathBuf, + file: Mutex>, +} + +impl EarlyFileLogger { + fn new(path: PathBuf) -> Self { + let file = (|| { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + OpenOptions::new().create(true).append(true).open(&path) + })(); + let file = match file { + Ok(file) => Some(file), + Err(error) => { + eprintln!( + "Warning: Failed to open early startup log {}, falling back to stderr: {}", + path.display(), + error + ); + None + } + }; + Self { + path, + file: Mutex::new(file), + } + } + + fn write_record(&self, record: &log::Record<'_>) { + let line = format_early_log_record(record); + let Ok(mut file_guard) = self.file.lock() else { + eprintln!("Warning: Early startup log writer lock is poisoned"); + return; + }; + let Some(file) = file_guard.as_mut() else { + eprintln!("{}", line.trim_end()); + return; + }; + if let Err(error) = file.write_all(line.as_bytes()).and_then(|_| file.flush()) { + eprintln!( + "Warning: Failed to write early startup log {}: {}", + self.path.display(), + error + ); + *file_guard = None; + eprintln!("{}", line.trim_end()); + } + } + + fn write_handoff_boundary(&self) { + let Ok(mut file_guard) = self.file.lock() else { + eprintln!("Warning: Early startup log writer lock is poisoned during handoff"); + return; + }; + let Some(file) = file_guard.as_mut() else { + return; + }; + let line = format!( + "[{}][tid:{}][INFO][bitfun_desktop::logging] Early startup logging handoff completed: runtime_backend=tauri_plugin_log\n", + Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), + get_thread_id() + ); + if let Err(error) = file.write_all(line.as_bytes()).and_then(|_| file.flush()) { + eprintln!( + "Warning: Failed to finalize early startup log {}: {}", + self.path.display(), + error + ); + } + } +} + +enum LogBackend { + Early(EarlyFileLogger), + Runtime(Box), +} + +struct SwitchingLogger { + backend: RwLock, +} + +impl SwitchingLogger { + fn new(early_logger: EarlyFileLogger) -> Self { + Self { + backend: RwLock::new(LogBackend::Early(early_logger)), + } + } + + fn install_runtime_backend( + &self, + runtime_logger: Box, + ) -> Result { + let mut backend = self + .backend + .write() + .map_err(|_| "Global log router lock is poisoned".to_string())?; + let early_log_path = match &*backend { + LogBackend::Early(early_logger) => { + early_logger.write_handoff_boundary(); + early_logger.path.clone() + } + LogBackend::Runtime(_) => { + return Err("Runtime logging backend is already installed".to_string()) + } + }; + *backend = LogBackend::Runtime(runtime_logger); + Ok(early_log_path) + } +} + +impl log::Log for SwitchingLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + self.backend + .read() + .map(|backend| match &*backend { + LogBackend::Early(_) => true, + LogBackend::Runtime(logger) => logger.enabled(metadata), + }) + .unwrap_or(false) + } + + fn log(&self, record: &log::Record<'_>) { + if let Ok(backend) = self.backend.read() { + match &*backend { + LogBackend::Early(logger) => logger.write_record(record), + LogBackend::Runtime(logger) => logger.log(record), + } + } + } + + fn flush(&self) { + if let Ok(backend) = self.backend.read() { + match &*backend { + LogBackend::Early(logger) => { + if let Ok(mut file_guard) = logger.file.lock() { + if let Some(file) = file_guard.as_mut() { + let _ = file.flush(); + } + } + } + LogBackend::Runtime(logger) => logger.flush(), + } + } + } +} + +fn format_early_log_record(record: &log::Record<'_>) -> String { + format!( + "[{}][tid:{}][{}][{}] {}\n", + Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), + get_thread_id(), + record.level(), + record.target(), + record.args() + ) +} + fn get_thread_id() -> u64 { let thread_id = thread::current().id(); let id_str = format!("{:?}", thread_id); @@ -80,6 +242,31 @@ impl LogConfig { } } +pub fn early_startup_log_path(session_log_dir: &Path) -> PathBuf { + session_log_dir.join(EARLY_STARTUP_LOG_FILE_NAME) +} + +pub fn native_startup_trace_path(session_log_dir: &Path) -> PathBuf { + session_log_dir.join(NATIVE_STARTUP_TRACE_FILE_NAME) +} + +pub fn install_early_file_logging(session_log_dir: &Path) -> Result<(), String> { + let early_log_path = early_startup_log_path(session_log_dir); + let early_logger = EarlyFileLogger::new(early_log_path.clone()); + let router = Box::leak(Box::new(SwitchingLogger::new(early_logger))); + log::set_logger(router) + .map_err(|_| "Failed to install global early startup logger".to_string())?; + GLOBAL_LOG_ROUTER + .set(router) + .map_err(|_| "Global early startup logger is already registered".to_string())?; + log::set_max_level(log::LevelFilter::Trace); + log::info!( + "Early startup logging initialized: path={}", + early_log_path.display() + ); + Ok(()) +} + const fn level_filter_to_u8(level: log::LevelFilter) -> u8 { match level { log::LevelFilter::Off => 0, @@ -172,6 +359,8 @@ pub fn flow_chat_log_path() -> PathBuf { pub struct RuntimeLoggingInfo { pub effective_level: String, pub session_log_dir: String, + pub early_startup_log_path: String, + pub native_startup_trace_path: String, pub app_log_path: String, pub ai_log_path: String, pub flashgrep_log_path: String, @@ -187,6 +376,12 @@ pub fn get_runtime_logging_info() -> RuntimeLoggingInfo { RuntimeLoggingInfo { effective_level: level_to_str(current_runtime_log_level()).to_string(), session_log_dir: session_dir.to_string_lossy().to_string(), + early_startup_log_path: early_startup_log_path(&session_dir) + .to_string_lossy() + .to_string(), + native_startup_trace_path: native_startup_trace_path(&session_dir) + .to_string_lossy() + .to_string(), app_log_path: session_dir.join("app.log").to_string_lossy().to_string(), ai_log_path: session_dir.join("ai.log").to_string_lossy().to_string(), flashgrep_log_path: session_dir @@ -418,7 +613,7 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { targets } -pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin { +fn configured_log_builder(log_targets: Vec) -> tauri_plugin_log::Builder { tauri_plugin_log::Builder::new() .level(log::LevelFilter::Trace) .level_for("ignore", log::LevelFilter::Off) @@ -456,6 +651,34 @@ pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin .max_file_size(10 * 1024 * 1024) .timezone_strategy(TimezoneStrategy::UseLocal) .clear_format() +} + +pub fn build_log_command_plugin() -> TauriPlugin { + tauri_plugin_log::Builder::new().skip_logger().build() +} + +pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin { + configured_log_builder(log_targets).build() +} + +pub fn build_log_handoff_plugin(log_targets: Vec) -> TauriPlugin { + tauri::plugin::Builder::new("logging-handoff") + .setup(move |app_handle, _api| { + let (_unused_plugin, max_level, runtime_logger) = + configured_log_builder(log_targets).split(app_handle)?; + let router = GLOBAL_LOG_ROUTER.get().copied().ok_or_else(|| { + std::io::Error::other("Global early startup logger is not installed") + })?; + let early_log_path = router + .install_runtime_backend(runtime_logger) + .map_err(std::io::Error::other)?; + log::set_max_level(max_level); + log::info!( + "Runtime logging backend ready: early_startup_log_path={}", + early_log_path.display() + ); + Ok(()) + }) .build() } @@ -552,6 +775,24 @@ pub fn spawn_log_cleanup_task() { mod tests { use super::*; + #[test] + fn early_file_logger_persists_and_flushes_each_record() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let path = temp_dir.path().join(EARLY_STARTUP_LOG_FILE_NAME); + let logger = EarlyFileLogger::new(path.clone()); + let record = log::Record::builder() + .level(log::Level::Error) + .target("bitfun_desktop::startup") + .args(format_args!("Startup failed: code={}", 7)) + .build(); + + logger.write_record(&record); + + let content = fs::read_to_string(path).expect("read early log"); + assert!(content.contains("[ERROR][bitfun_desktop::startup]")); + assert!(content.contains("Startup failed: code=7")); + } + #[test] fn serializes_flow_chat_diagnostics_as_bounded_json_lines() { let entries = vec![ diff --git a/src/apps/desktop/src/startup_trace.rs b/src/apps/desktop/src/startup_trace.rs index ba47c32ec0..787f0d414a 100644 --- a/src/apps/desktop/src/startup_trace.rs +++ b/src/apps/desktop/src/startup_trace.rs @@ -1,3 +1,6 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -35,6 +38,12 @@ pub struct DesktopStartupTrace { trace_id: String, started_at: Instant, events: Arc>>, + persistence: Arc>>, +} + +struct StartupTracePersistence { + path: PathBuf, + file: File, } impl DesktopStartupTrace { @@ -43,9 +52,44 @@ impl DesktopStartupTrace { trace_id, started_at, events: Arc::new(Mutex::new(Vec::new())), + persistence: Arc::new(Mutex::new(None)), } } + pub fn new_persisted( + trace_id: String, + started_at: Instant, + path: impl AsRef, + ) -> Result { + let path = path.as_ref().to_path_buf(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create native startup trace directory {}: {}", + parent.display(), + error + ) + })?; + } + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| { + format!( + "Failed to open native startup trace {}: {}", + path.display(), + error + ) + })?; + Ok(Self { + trace_id, + started_at, + events: Arc::new(Mutex::new(Vec::new())), + persistence: Arc::new(Mutex::new(Some(StartupTracePersistence { path, file }))), + }) + } + pub fn trace_id(&self) -> &str { &self.trace_id } @@ -113,6 +157,13 @@ impl DesktopStartupTrace { } } + pub fn record_logging_ready_and_stop_persistence(&self) { + self.record_phase("logging_ready", "native_logging"); + if let Ok(mut persistence) = self.persistence.lock() { + *persistence = None; + } + } + fn record_event( &self, phase: String, @@ -137,7 +188,47 @@ impl DesktopStartupTrace { }; if let Ok(mut events) = self.events.lock() { - events.push(event); + events.push(event.clone()); + } + self.persist_event(&event); + } + + fn persist_event(&self, event: &DesktopStartupTraceEvent) { + let serialized = match serde_json::to_vec(event) { + Ok(serialized) => serialized, + Err(error) => { + log::warn!("Failed to serialize native startup trace event: {}", error); + return; + } + }; + let failure = { + let Ok(mut persistence_guard) = self.persistence.lock() else { + log::warn!("Native startup trace writer lock is poisoned"); + return; + }; + let Some(persistence) = persistence_guard.as_mut() else { + return; + }; + let result = persistence + .file + .write_all(&serialized) + .and_then(|_| persistence.file.write_all(b"\n")) + .and_then(|_| persistence.file.flush()); + match result { + Ok(()) => None, + Err(error) => { + let path = persistence.path.clone(); + *persistence_guard = None; + Some((path, error)) + } + } + }; + if let Some((path, error)) = failure { + log::warn!( + "Failed to persist native startup trace, disabling writer: path={}, error={}", + path.display(), + error + ); } } } @@ -172,4 +263,25 @@ mod tests { assert_eq!(event.target.as_deref(), Some("app.auto_update")); assert!(event.duration_ms.unwrap_or_default() >= 7); } + + #[test] + fn persists_each_startup_event_as_flushed_json_line() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let path = temp_dir.path().join("native-startup-trace.jsonl"); + let trace = DesktopStartupTrace::new_persisted( + "trace-persisted".to_string(), + Instant::now(), + &path, + ) + .expect("create persisted trace"); + + trace.record_step("native_step_end", "native_pre_tauri", "load_config", 12); + + let content = std::fs::read_to_string(path).expect("read persisted trace"); + let event: serde_json::Value = + serde_json::from_str(content.trim()).expect("parse JSON line"); + assert_eq!(event["traceId"], "trace-persisted"); + assert_eq!(event["step"], "load_config"); + assert_eq!(event["durationMs"], 12); + } } diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 3f8dc20f3a..631e8f4661 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -730,6 +730,8 @@ export interface ConfigPanelProps { export interface RuntimeLoggingInfo { effectiveLevel: BackendLogLevel; sessionLogDir: string; + earlyStartupLogPath: string; + nativeStartupTracePath: string; appLogPath: string; aiLogPath: string; flashgrepLogPath: string; From 75108968e0750869b8f57287673b8922ef57ca68 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 16:36:19 -0700 Subject: [PATCH 024/206] docs: add current CLI TUI screenshot --- png/bitfun_cli_tui.png | Bin 0 -> 29283 bytes src/apps/cli/README.md | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 png/bitfun_cli_tui.png diff --git a/png/bitfun_cli_tui.png b/png/bitfun_cli_tui.png new file mode 100644 index 0000000000000000000000000000000000000000..4a9c1d7fab6e0784d72acb3f3981cd5f04f11a1b GIT binary patch literal 29283 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 literal 0 HcmV?d00001 diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index b80edfdba0..97d7f7dce5 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -5,6 +5,8 @@ session management, and machine-owned background tasks. Use `bitfun` for all new scripts and integrations; `bitfun-cli` is a deprecated compatibility entrypoint. +![BitFun interactive TUI](../../../png/bitfun_cli_tui.png) + ## Install From the repository root: From df6593dc47d06e61ed369068c00f180c11a94229 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 6 Aug 2026 23:38:59 +0800 Subject: [PATCH 025/206] fix(release): publish versioned manual installer metadata --- .github/workflows/ci.yml | 8 ++ .github/workflows/desktop-package.yml | 27 +++++- .github/workflows/nightly.yml | 27 +++++- scripts/generate-tauri-latest-json.mjs | 17 ++++ scripts/linux-binaries-manifest.test.mjs | 58 ++++++++++-- scripts/openbitfun-release-sync.sh | 97 +++++++++++++++++---- scripts/prepare-windows-installer-asset.mjs | 65 ++++++++++++++ scripts/tauri-release-manifest.test.mjs | 83 ++++++++++++++++++ scripts/verify-release-version-sync.mjs | 48 ++++++++++ scripts/verify-tauri-latest-json.mjs | 24 +++++ 10 files changed, 426 insertions(+), 28 deletions(-) create mode 100644 scripts/prepare-windows-installer-asset.mjs create mode 100644 scripts/tauri-release-manifest.test.mjs create mode 100644 scripts/verify-release-version-sync.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f5f466aa3..1b3df8c002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + - name: Reject CRLF in shell and deploy assets run: | bad=$(git ls-files -z \ @@ -53,6 +58,9 @@ jobs: done < <(git ls-files -z '*.sh' '*.bash') exit "$rc" + - name: Verify release manifest and mirror contracts + run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs + - name: Verify minisign download fallback run: | set -euo pipefail diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 03c2202abc..32a861682b 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -233,6 +233,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify release version metadata + run: node scripts/verify-release-version-sync.mjs --version "${{ needs.prepare.outputs.version }}" + - name: Build desktop app run: ${{ matrix.platform.build_command }} @@ -505,6 +508,20 @@ jobs: echo "Relay image descriptor:" find relay-image-assets -type f | sort + - name: Prepare versioned Windows installer + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --out-dir release-manual-assets + + - name: Sign versioned Windows installer + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bash scripts/sign-release-assets.sh release-manual-assets/*.exe + - name: Collect updater assets run: | node scripts/collect-tauri-updater-assets.mjs \ @@ -517,6 +534,7 @@ jobs: run: | node scripts/generate-tauri-latest-json.mjs \ --assets-dir release-updater-assets \ + --manual-assets-dir release-manual-assets \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.release_tag }}" \ --repo "GCWing/BitFun" \ @@ -528,7 +546,8 @@ jobs: node scripts/verify-tauri-latest-json.mjs \ --manifest release-updater-assets/latest.json \ --version "${{ needs.prepare.outputs.version }}" \ - --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" - name: Generate Linux binaries manifest run: | @@ -556,7 +575,7 @@ jobs: mapfile -t assets < <( find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort + -o -name '*.dmg' \) | sort ) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." @@ -575,11 +594,12 @@ jobs: generate_release_notes: true files: | release-updater-assets/* + release-manual-assets/*.exe + release-manual-assets/*.exe.sig release-assets/**/*.AppImage release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe release-assets/**/*.sig release-assets/minisign.pub linux-release-assets/bitfun-cli-*.tar.gz @@ -602,6 +622,7 @@ jobs: --manifest latest.published.json \ --version "${{ needs.prepare.outputs.version }}" \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" \ --check-urls true - name: Verify published Linux binaries manifest diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 16473c1eea..8c6b44a652 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -180,6 +180,12 @@ jobs: const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); pkg.version = process.env.NIGHTLY_VERSION.split('+')[0]; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + for (const file of ['BitFun-Installer/package.json', 'BitFun-Installer/package-lock.json']) { + const data = JSON.parse(fs.readFileSync(file, 'utf-8')); + data.version = pkg.version; + if (data.packages?.['']) data.packages[''].version = pkg.version; + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n'); + } " # Patch Cargo workspace version (semver: nightly suffix uses hyphen) @@ -187,6 +193,8 @@ jobs: CARGO_VERSION="$(echo "$NIGHTLY_VERSION" | sed 's/+.*//')" sed -i.bak "s/^version = \".*\" # x-release-please-version/version = \"${CARGO_VERSION}\" # x-release-please-version/" Cargo.toml rm -f Cargo.toml.bak + sed -i.bak "0,/^version = \".*\"/s//version = \"${CARGO_VERSION}\"/" BitFun-Installer/src-tauri/Cargo.toml + rm -f BitFun-Installer/src-tauri/Cargo.toml.bak echo "package.json version: $(jq -r '.version' package.json)" echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" @@ -418,6 +426,15 @@ jobs: --repo "GCWing/BitFun" \ --out linux-release-assets/linux-binaries.json + - name: Prepare versioned Windows installer + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${NIGHTLY_VERSION%%+*}" \ + --out-dir release-manual-assets + # The Tauri bundler signs the five updater artifacts during `tauri build`, # but the installers people download by hand from the release page — dmg, # deb, rpm, the Windows installer and the direct AppImages — shipped with @@ -432,11 +449,12 @@ jobs: BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} run: | set -euo pipefail - mapfile -t assets < <( + mapfile -t assets < <({ find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort - ) + -o -name '*.dmg' \) + find release-manual-assets -type f -name '*.exe' + } | sort) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." exit 0 @@ -466,8 +484,9 @@ jobs: release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe release-assets/**/*.sig + release-manual-assets/*.exe + release-manual-assets/*.exe.sig release-assets/minisign.pub release-assets/**/bitfun-cli-*-apple-darwin.tar.gz release-assets/**/bitfun-cli-*-apple-darwin.tar.gz.sha256 diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs index b0f6ee57d0..f8f47f6c1d 100644 --- a/scripts/generate-tauri-latest-json.mjs +++ b/scripts/generate-tauri-latest-json.mjs @@ -9,6 +9,7 @@ const tag = requireArg(args, 'tag'); const repo = requireArg(args, 'repo'); const out = requireArg(args, 'out'); const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const manualAssetsDir = args['manual-assets-dir']; if (!existsSync(assetsDir)) { fail(`Assets directory does not exist: ${assetsDir}`); @@ -55,6 +56,22 @@ const manifest = { platforms, }; +if (manualAssetsDir) { + const installerName = `BitFun_${version}_windows-x86_64-installer.exe`; + const installerPath = join(manualAssetsDir, installerName); + const signaturePath = `${installerPath}.sig`; + if (!existsSync(installerPath) || !existsSync(signaturePath)) { + fail(`Missing signed manual installer pair: ${installerPath} and ${signaturePath}`); + } + const assetUrl = `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(installerName)}`; + manifest.manual_installers = { + 'windows-x86_64': { + url: assetUrl, + signature_url: `${assetUrl}.sig`, + }, + }; +} + mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); console.log(`[latest-json] Wrote ${out}`); diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 50862f1d8b..01dcc038f4 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -181,7 +181,7 @@ test('openbitfun sync mirrors the website installer from the exact updater relea source "$SYNC_SCRIPT" VERSION_DIR="$TEST_VERSION_DIR" RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.3" - WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" + LATEST_JSON="$TEST_LATEST_JSON" download_asset() { printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" } @@ -194,6 +194,14 @@ test('openbitfun sync mirrors the website installer from the exact updater relea DOWNLOAD_CALLS: calls, SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), TEST_VERSION_DIR: versionDir, + TEST_LATEST_JSON: JSON.stringify({ + manual_installers: { + 'windows-x86_64': { + url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, + }), }, } ); @@ -201,8 +209,42 @@ test('openbitfun sync mirrors the website installer from the exact updater relea const downloads = fs.readFileSync(calls, 'utf8').trim().split('\n'); assert.deepEqual(downloads, [ - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe.sig`, + ]); +}); + +test('openbitfun sync retains the legacy fixed installer fallback', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-legacy-installer-mirror-')); + const versionDir = path.join(temp, 'release', '1.2.2'); + const calls = path.join(temp, 'download-calls.tsv'); + fs.mkdirSync(versionDir, { recursive: true }); + + const result = spawnSync( + 'bash', + ['-c', ` + source "$SYNC_SCRIPT" + VERSION_DIR="$TEST_VERSION_DIR" + RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.2" + download_asset() { + printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" + } + mirror_windows_installer + `], + { + encoding: 'utf8', + env: { + ...process.env, + DOWNLOAD_CALLS: calls, + SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + TEST_VERSION_DIR: versionDir, + }, + } + ); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(calls, 'utf8').trim().split('\n'), [ + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, ]); }); @@ -278,6 +320,12 @@ test('website download manifest uses installer while updater manifest keeps setu url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_darwin-aarch64.app.tar.gz', }, }, + manual_installers: { + 'windows-x86_64': { + url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, }; fs.writeFileSync(updaterPath, `${JSON.stringify(updater, null, 2)}\n`); @@ -314,11 +362,11 @@ test('website download manifest uses installer while updater manifest keeps setu assert.equal(website.version, '1.2.3'); assert.equal( website.platforms['windows-x86_64'].url, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe' ); assert.equal( website.platforms['windows-x86_64'].signatureUrl, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe.sig' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig' ); assert.equal( website.platforms['darwin-aarch64'].url, diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index b880a26f36..b29f3fcade 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -15,9 +15,9 @@ # The published release/latest.json is the Tauri updater fallback endpoint. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest.json and downloads from this mirror. -# The published release/downloads.json is for the website. Its Windows URL -# points at bitfun-installer.exe while latest.json deliberately keeps the Tauri -# updater's versioned setup.exe URL. +# The published release/downloads.json is for the website. Its Windows URL uses +# latest.json's manual_installers entry while the updater keeps the versioned +# Tauri setup.exe URL. # # Cron (every 10 minutes): # */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ @@ -48,7 +48,10 @@ GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/downloa OPENBITFUN_BASE_URL="https://openbitfun.com/release" WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" -WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" +WINDOWS_INSTALLER_URL="" +WINDOWS_INSTALLER_SIGNATURE_URL="" WEBSITE_DOWNLOADS_MANIFEST="downloads.json" # Keep enough releases that the mirror still serves a Desktop build a few # versions behind and SSH Dispatch can finish an already-confirmed install even @@ -110,22 +113,47 @@ publish_file_atomically() { # /releases/latest/download so the installer and setup package cannot come from # different releases while GitHub is advancing the latest-release pointer. mirror_windows_installer() { - local installer_url - installer_url="${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}" + local installer_url signature_url metadata + installer_url="${WINDOWS_INSTALLER_URL:-${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}}" + signature_url="${WINDOWS_INSTALLER_SIGNATURE_URL:-${installer_url}.sig}" + + if [ -n "${LATEST_JSON:-}" ]; then + metadata=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") + if [ -n "$metadata" ]; then + installer_url=$(printf '%s\n' "$metadata" | sed -n '1p') + signature_url=$(printf '%s\n' "$metadata" | sed -n '2p') + if [ "${installer_url%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to the updater release: $installer_url" + return 1 + fi + if [ "$signature_url" != "${installer_url}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + return 1 + fi + WINDOWS_INSTALLER_FILENAME="${installer_url##*/}" + fi + fi log " Mirroring website Windows installer: ${WINDOWS_INSTALLER_FILENAME}" download_asset \ "$installer_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}" || exit 1 download_asset \ - "${installer_url}.sig" \ + "$signature_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1 } # Build a website-only manifest from the already rewritten updater manifest. # All non-Windows targets continue to use their mirrored updater packages. The -# Windows target alone is replaced with the custom installer URL; latest.json -# is never modified and remains a valid Tauri updater contract. +# Windows target alone is replaced with the custom installer URL. The updater +# URL remains untouched; manual_installers is a mirror/website extension only. write_website_download_manifest() { local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" local output_tmp="${output}.part" @@ -152,9 +180,14 @@ windows = platforms.get("windows-x86_64") if windows is None: raise SystemExit("latest.json is missing windows-x86_64") -version_base = f"{base}/{version}" -windows["url"] = f"{version_base}/{windows_installer}" -windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" +manual = updater.get("manual_installers", {}).get("windows-x86_64") +if manual: + windows["url"] = manual["url"] + windows["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") +else: + version_base = f"{base}/{version}" + windows["url"] = f"{version_base}/{windows_installer}" + windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" website = { "schemaVersion": 1, @@ -480,8 +513,8 @@ main() { log "Latest version: $VERSION" # Resolve the exact tagged release directory from the updater URLs. Using - # this base for the standalone installer avoids a latest-release race where - # latest.json and bitfun-installer.exe could otherwise resolve to different +# this base for the standalone installer avoids a latest-release race where +# latest.json and the manual installer could otherwise resolve to different # versions during publication. RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import json, sys @@ -495,6 +528,35 @@ print(bases.pop()) exit 1 } + INSTALLER_METADATA=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") || { + log "ERROR: Failed to resolve the manual Windows installer from latest.json" + exit 1 + } + if [ -n "$INSTALLER_METADATA" ]; then + WINDOWS_INSTALLER_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '1p') + WINDOWS_INSTALLER_SIGNATURE_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '2p') + if [ "${WINDOWS_INSTALLER_URL%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to release $VERSION" + exit 1 + fi + if [ "$WINDOWS_INSTALLER_SIGNATURE_URL" != "${WINDOWS_INSTALLER_URL}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + exit 1 + fi + WINDOWS_INSTALLER_FILENAME="${WINDOWS_INSTALLER_URL##*/}" + else + WINDOWS_INSTALLER_URL="${RELEASE_ASSET_BASE_URL}/${LEGACY_WINDOWS_INSTALLER_FILENAME}" + WINDOWS_INSTALLER_SIGNATURE_URL="${WINDOWS_INSTALLER_URL}.sig" + WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" + fi + # 3. Create version directory VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" @@ -524,8 +586,7 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # latest.json only lists the Tauri setup.exe. Mirror the custom installer - # separately for website users while preserving the updater contract. + # Mirror the manual installer separately while preserving the updater URL. mirror_windows_installer # 6. Rewrite URLs in latest.json to point at openbitfun.com @@ -538,6 +599,10 @@ base = '${OPENBITFUN_BASE_URL}/' + version for p, info in data.get('platforms', {}).items(): fname = info['url'].split('/')[-1] info['url'] = base + '/' + fname +for p, info in data.get('manual_installers', {}).items(): + for key in ('url', 'signature_url'): + if info.get(key): + info[key] = base + '/' + info[key].split('/')[-1] print(json.dumps(data, indent=2)) " > "$LATEST_MANIFEST_TMP" mv "$LATEST_MANIFEST_TMP" "${VERSION_DIR}/latest.json" diff --git a/scripts/prepare-windows-installer-asset.mjs b/scripts/prepare-windows-installer-asset.mjs new file mode 100644 index 0000000000..3761d58142 --- /dev/null +++ b/scripts/prepare-windows-installer-asset.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { basename, join } from 'path'; + +const args = parseArgs(process.argv.slice(2)); +const assetsDir = requireArg(args, 'assets-dir'); +const version = requireArg(args, 'version'); +const outDir = requireArg(args, 'out-dir'); + +if (!existsSync(assetsDir)) { + fail(`Assets directory does not exist: ${assetsDir}`); +} +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + fail(`Version is not safe for a release asset name: ${version}`); +} + +const candidates = walkFiles(assetsDir).filter( + (file) => basename(file).toLowerCase() === 'bitfun-installer.exe' +); +if (candidates.length !== 1) { + fail(`Expected exactly one bitfun-installer.exe, found ${candidates.length}`); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const outputName = `BitFun_${version}_windows-x86_64-installer.exe`; +const outputPath = join(outDir, outputName); +copyFileSync(candidates[0], outputPath); +console.log(`[manual-installer] ${candidates[0]} -> ${outputPath}`); + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) fail(`Missing value for --${key}`); + parsed[key] = value; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) fail(`Missing required argument --${key}`); + return value; +} + +function walkFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) files.push(...walkFiles(fullPath)); + else if (entry.isFile()) files.push(fullPath); + } + return files; +} + +function fail(message) { + console.error(`[manual-installer] ${message}`); + process.exit(1); +} diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs new file mode 100644 index 0000000000..17c4a162f2 --- /dev/null +++ b/scripts/tauri-release-manifest.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); + +test('release version metadata is synchronized', () => { + const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const result = run('scripts/verify-release-version-sync.mjs', ['--version', version]); + assert.equal(result.status, 0, result.stderr); +}); + +test('prepares a versioned custom Windows installer asset', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-manual-installer-')); + const assets = path.join(temp, 'assets', 'nested'); + const out = path.join(temp, 'manual'); + fs.mkdirSync(assets, { recursive: true }); + fs.writeFileSync(path.join(assets, 'bitfun-installer.exe'), 'installer'); + + const result = run('scripts/prepare-windows-installer-asset.mjs', [ + '--assets-dir', path.join(temp, 'assets'), + '--version', '1.2.3', + '--out-dir', out, + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(out, 'BitFun_1.2.3_windows-x86_64-installer.exe'), 'utf8'), + 'installer' + ); +}); + +test('latest.json keeps the updater URL separate from the manual installer URL', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-latest-manual-')); + const updater = path.join(temp, 'updater'); + const manual = path.join(temp, 'manual'); + const out = path.join(temp, 'latest.json'); + fs.mkdirSync(updater, { recursive: true }); + fs.mkdirSync(manual, { recursive: true }); + + const updaterName = 'BitFun_1.2.3_windows-x86_64-setup.exe'; + fs.writeFileSync(path.join(updater, updaterName), 'setup'); + fs.writeFileSync(path.join(updater, `${updaterName}.sig`), 'inline-updater-signature'); + const installerName = 'BitFun_1.2.3_windows-x86_64-installer.exe'; + fs.writeFileSync(path.join(manual, installerName), 'installer'); + fs.writeFileSync(path.join(manual, `${installerName}.sig`), 'detached-signature'); + + const generated = run('scripts/generate-tauri-latest-json.mjs', [ + '--assets-dir', updater, + '--manual-assets-dir', manual, + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/BitFun', + '--out', out, + '--required-platforms', 'windows-x86_64', + ]); + assert.equal(generated.status, 0, generated.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.match(manifest.platforms['windows-x86_64'].url, /-setup\.exe$/); + assert.match(manifest.manual_installers['windows-x86_64'].url, /-installer\.exe$/); + assert.equal( + manifest.manual_installers['windows-x86_64'].signature_url, + `${manifest.manual_installers['windows-x86_64'].url}.sig` + ); + + const verified = run('scripts/verify-tauri-latest-json.mjs', [ + '--manifest', out, + '--version', '1.2.3', + '--required-platforms', 'windows-x86_64', + '--required-manual-platforms', 'windows-x86_64', + ]); + assert.equal(verified.status, 0, verified.stderr); +}); + +function run(script, args) { + return spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: 'utf8', + }); +} diff --git a/scripts/verify-release-version-sync.mjs b/scripts/verify-release-version-sync.mjs new file mode 100644 index 0000000000..9f9ddd49ee --- /dev/null +++ b/scripts/verify-release-version-sync.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; + +const args = parseArgs(process.argv.slice(2)); +const expected = requireArg(args, 'version'); +const versions = new Map([ + ['package.json', readJsonVersion('package.json')], + ['package-lock.json', readJsonVersion('package-lock.json')], + ['Cargo.toml', readTomlVersion('Cargo.toml', /version = "([^"]+)" # x-release-please-version/)], + ['BitFun-Installer/package.json', readJsonVersion('BitFun-Installer/package.json')], + ['BitFun-Installer/package-lock.json', readJsonVersion('BitFun-Installer/package-lock.json')], + ['BitFun-Installer/src-tauri/Cargo.toml', readTomlVersion('BitFun-Installer/src-tauri/Cargo.toml', /^version = "([^"]+)"/m)], +]); + +const mismatches = [...versions].filter(([, version]) => version !== expected); +if (mismatches.length > 0) { + for (const [file, version] of mismatches) { + console.error(`[release-version] ${file}: expected ${expected}, found ${version}`); + } + process.exit(1); +} +console.log(`[release-version] OK: ${expected}`); + +function readJsonVersion(file) { + return JSON.parse(readFileSync(file, 'utf8')).version; +} + +function readTomlVersion(file, pattern) { + const match = pattern.exec(readFileSync(file, 'utf8')); + if (!match) throw new Error(`Version was not found in ${file}`); + return match[1]; +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + parsed[arg.slice(2)] = rawArgs[i + 1]; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + if (!parsed[key]) throw new Error(`Missing required argument --${key}`); + return parsed[key]; +} diff --git a/scripts/verify-tauri-latest-json.mjs b/scripts/verify-tauri-latest-json.mjs index 81d7c00afc..a4386d12b3 100644 --- a/scripts/verify-tauri-latest-json.mjs +++ b/scripts/verify-tauri-latest-json.mjs @@ -5,6 +5,7 @@ const args = parseArgs(process.argv.slice(2)); const manifestPath = requireArg(args, 'manifest'); const version = args.version; const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const requiredManualPlatforms = parseListArg(args['required-manual-platforms'] || ''); const checkUrls = ['1', 'true', 'yes'].includes(String(args['check-urls'] || '').toLowerCase()); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); @@ -33,10 +34,33 @@ for (const [platform, entry] of Object.entries(manifest.platforms)) { } } +const manualInstallers = manifest.manual_installers || {}; +const missingManual = requiredManualPlatforms.filter((platform) => !manualInstallers[platform]); +if (missingManual.length > 0) { + fail(`Missing required manual installers: ${missingManual.join(', ')}`); +} +for (const [platform, entry] of Object.entries(manualInstallers)) { + if (!entry || typeof entry !== 'object') fail(`Invalid manual installer entry for ${platform}`); + if (!entry.url || typeof entry.url !== 'string') fail(`Missing manual installer URL for ${platform}`); + if (!entry.signature_url || typeof entry.signature_url !== 'string') { + fail(`Missing manual installer signature URL for ${platform}`); + } + if (entry.signature_url !== `${entry.url}.sig`) { + fail(`Manual installer signature URL for ${platform} must equal url + .sig`); + } + if (manifest.platforms[platform]?.url === entry.url) { + fail(`Manual installer URL for ${platform} must not replace the updater URL`); + } +} + if (checkUrls) { for (const [platform, entry] of Object.entries(manifest.platforms)) { await assertUrlAvailable(platform, entry.url); } + for (const [platform, entry] of Object.entries(manualInstallers)) { + await assertUrlAvailable(`manual installer ${platform}`, entry.url); + await assertUrlAvailable(`manual installer signature ${platform}`, entry.signature_url); + } } console.log(`[verify-latest-json] OK: ${Object.keys(manifest.platforms).sort().join(', ')}`); From 9f23d22debc35d08f9c557edf9249312b39ed135 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 6 Aug 2026 23:59:51 +0800 Subject: [PATCH 026/206] fix(desktop): use production app version in about dialog --- .github/workflows/ci.yml | 4 +- package.json | 3 +- scripts/dev.cjs | 6 ++- scripts/generate-version.cjs | 41 +++++++++++++------ scripts/version-generation.test.mjs | 37 +++++++++++++++++ .../components/AboutDialog/AboutDialog.tsx | 29 +++++++++++-- src/web-ui/src/shared/utils/version.test.ts | 27 ++++++++++++ src/web-ui/src/shared/utils/version.ts | 19 +++++++-- 8 files changed, 142 insertions(+), 24 deletions(-) create mode 100644 scripts/version-generation.test.mjs create mode 100644 src/web-ui/src/shared/utils/version.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3df8c002..0ee3415f43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,8 +58,8 @@ jobs: done < <(git ls-files -z '*.sh' '*.bash') exit "$rc" - - name: Verify release manifest and mirror contracts - run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs + - name: Verify release and version-generation contracts + run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs - name: Verify minisign download fallback run: | diff --git a/package.json b/package.json index 71bceae0b0..d37c05c7e5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "copy-monaco": "copyfiles -u 5 \"src/web-ui/node_modules/monaco-editor/min/vs/**/*\" src/web-ui/public/monaco-editor && node scripts/prune-monaco-nls.cjs", "copy-icons": "copyfiles -f \"src/apps/desktop/icons/Logo-ICON.png\" \"src/web-ui/public/\"", "copy-assets": "pnpm run copy-monaco && pnpm run copy-icons", - "generate-version": "node scripts/generate-version.cjs", + "generate-version": "node scripts/generate-version.cjs --build-env production", + "generate-version:dev": "node scripts/generate-version.cjs --build-env development", "generate-startup-appearance-bootstrap": "node scripts/generate-startup-appearance-bootstrap.mjs", "generate-all": "pnpm run generate-version && pnpm run generate-startup-appearance-bootstrap", "postinstall": "pnpm run copy-assets", diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 39918b36aa..d218cb06b4 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -666,7 +666,11 @@ async function main() { }, { name: 'Generate version info', - promise: runCommandPrefixed('version', 'node', ['scripts/generate-version.cjs']), + promise: runCommandPrefixed('version', 'node', [ + 'scripts/generate-version.cjs', + '--build-env', + 'development', + ]), }, ]; diff --git a/scripts/generate-version.cjs b/scripts/generate-version.cjs index b6c2acf273..0081e52f2e 100644 --- a/scripts/generate-version.cjs +++ b/scripts/generate-version.cjs @@ -19,6 +19,20 @@ const { const packageJsonPath = path.resolve(__dirname, '../package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); +function parseBuildEnv(args) { + const index = args.indexOf('--build-env'); + const buildEnv = index >= 0 ? args[index + 1] : undefined; + if (!['development', 'production', 'preview'].includes(buildEnv)) { + throw new Error('Expected --build-env development|production|preview'); + } + return buildEnv; +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + function getGitInfo() { try { const gitCommitFull = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(); @@ -40,11 +54,10 @@ function getGitInfo() { } } -function generateVersionInfo() { +function generateVersionInfo(buildEnv) { const gitInfo = getGitInfo(); const buildDate = new Date().toISOString(); const buildTimestamp = Date.now(); - const buildEnv = process.env.NODE_ENV || 'development'; const isDev = buildEnv === 'development'; const versionInfo = { @@ -60,8 +73,8 @@ function generateVersionInfo() { return versionInfo; } -function saveVersionInfoToJson(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/public/version.json'); +function saveVersionInfoToJson(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/public/version.json'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -75,8 +88,8 @@ function saveVersionInfoToJson(versionInfo) { ); } -function saveVersionInfoToTS(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/src/generated/version.ts'); +function saveVersionInfoToTS(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version.ts'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -104,13 +117,16 @@ function generateHtmlInjectionScript(versionInfo) { } function main() { - const versionInfo = generateVersionInfo(); + const args = process.argv.slice(2); + const buildEnv = parseBuildEnv(args); + const outputRoot = path.resolve(readArg(args, '--output-root') || path.resolve(__dirname, '..')); + const versionInfo = generateVersionInfo(buildEnv); - saveVersionInfoToJson(versionInfo); - saveVersionInfoToTS(versionInfo); + saveVersionInfoToJson(versionInfo, outputRoot); + saveVersionInfoToTS(versionInfo, outputRoot); const htmlScript = generateHtmlInjectionScript(versionInfo); - const htmlScriptPath = path.resolve(__dirname, '../src/web-ui/src/generated/version-injection.html'); + const htmlScriptPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version-injection.html'); const htmlDir = path.dirname(htmlScriptPath); if (!fs.existsSync(htmlDir)) { @@ -123,12 +139,11 @@ function main() { printSuccess(`${versionInfo.name} v${versionInfo.version}${gitStr}`); } -// On failure: warn and exit 0 so build is not interrupted try { main(); } catch (err) { - printWarning('Version info generation failed, skipped: ' + (err.message || err)); - process.exit(0); + printWarning('Version info generation failed: ' + (err.message || err)); + process.exit(1); } diff --git a/scripts/version-generation.test.mjs b/scripts/version-generation.test.mjs new file mode 100644 index 0000000000..bd599fcfea --- /dev/null +++ b/scripts/version-generation.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); +const expectedVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + +for (const [buildEnv, isDev] of [['production', false], ['development', true]]) { + test(`generates ${buildEnv} version metadata explicitly`, () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `bitfun-version-${buildEnv}-`)); + const result = run(['--build-env', buildEnv, '--output-root', outputRoot]); + assert.equal(result.status, 0, result.stderr); + const generated = JSON.parse( + fs.readFileSync(path.join(outputRoot, 'src/web-ui/public/version.json'), 'utf8') + ); + assert.equal(generated.version, expectedVersion); + assert.equal(generated.buildEnv, buildEnv); + assert.equal(generated.isDev, isDev); + }); +} + +test('fails instead of reusing stale metadata when build environment is missing', () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-missing-')); + const result = run(['--output-root', outputRoot]); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /Expected --build-env/); +}); + +function run(args) { + return spawnSync(process.execPath, ['scripts/generate-version.cjs', ...args], { + cwd: root, + encoding: 'utf8', + }); +} diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 13953b80cf..b0a4175c82 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -10,7 +10,7 @@ import { Tooltip, Modal, Button, Alert } from '@/component-library'; import { Copy, Check, Download, CheckCircle2 } from 'lucide-react'; import { getAboutInfo, - formatVersion, + formatDisplayedVersion, formatBuildDate } from '@/shared/utils/version'; import { createLogger } from '@/shared/utils/logger'; @@ -42,6 +42,7 @@ export const AboutDialog: React.FC = ({ const [manualCheckErrorMessage, setManualCheckErrorMessage] = useState(null); const [manualOpen, setManualOpen] = useState(false); const [manualData, setManualData] = useState(null); + const [nativeVersion, setNativeVersion] = useState(null); const updateStatus = useUpdateInstallStore(state => state.status); const updateProgress = useUpdateInstallStore(state => state.progress); const updateError = useUpdateInstallStore(state => state.error); @@ -49,6 +50,13 @@ export const AboutDialog: React.FC = ({ const aboutInfo = getAboutInfo(); const { version, license } = aboutInfo; + const nativeRuntime = isTauriRuntime(); + const displayedVersion = formatDisplayedVersion( + version, + nativeVersion, + nativeRuntime, + import.meta.env.DEV + ); const updateProgressPercent = updateProgress.total != null && updateProgress.total > 0 ? Math.min(100, Math.round((updateProgress.downloaded / updateProgress.total) * 100)) @@ -61,6 +69,21 @@ export const AboutDialog: React.FC = ({ } }, [isOpen]); + useEffect(() => { + if (!isOpen || !nativeRuntime) return; + let active = true; + void systemAPI.getAppVersion() + .then(currentVersion => { + if (active) setNativeVersion(currentVersion); + }) + .catch(error => { + log.warn('get_app_version failed; using generated version metadata', error); + }); + return () => { + active = false; + }; + }, [isOpen, nativeRuntime]); + const handleCheckForUpdates = useCallback(async () => { if (!isTauriRuntime()) { return; @@ -134,7 +157,7 @@ export const AboutDialog: React.FC = ({

{version.name}

- {t('about.version', { version: formatVersion(version.version, version.isDev) })} + {t('about.version', { version: displayedVersion })}
@@ -146,7 +169,7 @@ export const AboutDialog: React.FC = ({ {/* Scrollable area */}
- {isTauriRuntime() ? ( + {nativeRuntime ? (
{ + it('uses the native package version without a dev suffix in production', () => { + expect(formatDisplayedVersion(productionInfo, '1.2.3', true, false)).toBe('1.2.3'); + }); + + it('marks a native development runtime as dev', () => { + expect(formatDisplayedVersion(productionInfo, '1.2.3', true, true)).toBe('1.2.3-dev'); + }); + + it('uses generated metadata for the web surface', () => { + const developmentInfo = { ...productionInfo, isDev: true, buildEnv: 'development' as const }; + expect(formatDisplayedVersion(developmentInfo, null, false, false)).toBe('1.2.2-dev'); + }); +}); diff --git a/src/web-ui/src/shared/utils/version.ts b/src/web-ui/src/shared/utils/version.ts index 54afec3e7c..843cf6f5cc 100644 --- a/src/web-ui/src/shared/utils/version.ts +++ b/src/web-ui/src/shared/utils/version.ts @@ -6,11 +6,11 @@ import { i18nService } from '@/infrastructure/i18n'; const DEFAULT_VERSION_INFO: VersionInfo = { name: 'BitFun', - version: '0.2.15', - buildDate: new Date().toISOString(), - buildTimestamp: Date.now(), + version: '0.0.0', + buildDate: new Date(0).toISOString(), + buildTimestamp: 0, isDev: import.meta.env.DEV, - buildEnv: import.meta.env.MODE as 'development' | 'production' | 'preview' + buildEnv: import.meta.env.MODE as VersionInfo['buildEnv'] }; @@ -43,6 +43,17 @@ export function formatVersion(version: string, isDev: boolean): string { return version; } +export function formatDisplayedVersion( + versionInfo: VersionInfo, + nativeVersion: string | null, + nativeRuntime: boolean, + frontendDev: boolean +): string { + const version = nativeVersion ?? versionInfo.version; + const isDev = nativeRuntime ? frontendDev : versionInfo.isDev; + return formatVersion(version, isDev); +} + export function formatBuildDate(buildDate: string): string { try { From 9b3848e299bdb72f472993bea6be734e875e5560 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 7 Aug 2026 00:01:08 +0800 Subject: [PATCH 027/206] fix(updater): install Windows updates silently --- scripts/desktop-tauri-build.mjs | 2 +- scripts/desktop-tauri-build.test.mjs | 33 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 3e7518258a..24bf28cf31 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -216,7 +216,7 @@ export function prepareTauriConfig( endpoints: [primaryEndpoint, fallbackEndpoint], pubkey, windows: { - installMode: 'passive', + installMode: 'quiet', }, }, }; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 777b0eb079..4f15bc11bd 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -125,6 +125,39 @@ test('Desktop Tauri projection consumes only the resolved member identity', () = } }); +test('Windows updater installs NSIS packages without showing its progress window', () => { + const fixture = join(tmpdir(), `bitfun-tauri-updater-${process.pid}-${Date.now()}`); + const baseConfig = join(fixture, 'tauri.conf.json'); + const updaterEnv = { + BITFUN_ENABLE_UPDATER_ARTIFACTS: process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, + TAURI_SIGNING_PRIVATE_KEY: process.env.TAURI_SIGNING_PRIVATE_KEY, + TAURI_UPDATER_PUBKEY: process.env.TAURI_UPDATER_PUBKEY, + }; + mkdirSync(fixture, { recursive: true }); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS = 'true'; + process.env.TAURI_SIGNING_PRIVATE_KEY = 'test-private-key'; + process.env.TAURI_UPDATER_PUBKEY = 'test-public-key'; + + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal(config.plugins.updater.windows.installMode, 'quiet'); + } finally { + for (const [name, value] of Object.entries(updaterEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') From 6aa5f123f35a4b9875d488198806ba3d04e91249 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 22:59:43 +0800 Subject: [PATCH 028/206] fix(review): allow ReviewFixer as a review session primary agent The remediation phase of a review child session runs with agentType=ReviewFixer (DeepReviewActionBar.handleStartFixing submits it on "Start fixing"). The primary-agent resolution added by ca94825ad only allowed CodeReview/DeepReview through, so the fix turn failed with "Failed to start dialog turn: Unknown session mode: ReviewFixer". The same resolution gate also broke manual compaction of ReviewFixer sessions and silently rewrote the session agent_type to agentic during restore. Add REVIEW_FIXER_AGENT_TYPE to the builtin session-primary whitelist so create, turn, restore, and compaction all resolve identically. ReviewWorker/ReviewJudge stay restricted. Registry tests now cover ReviewFixer through both the no-route fallback and explicit Local routes, plus the unchanged ReviewWorker/ReviewJudge and external-owner guards. --- .../src/agentic/agents/registry/external.rs | 17 +++++-- .../core/src/agentic/agents/registry/tests.rs | 49 +++++++++++++------ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index 96326a327b..a9210cafc9 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -1,7 +1,9 @@ use super::types::{AgentCategory, AgentEntry, AgentInfo, AgentSource, SubAgentSource}; use super::AgentRegistry; use crate::agentic::agents::{Agent, SubagentVisibilityPolicy}; -use crate::agentic::deep_review_policy::{CODE_REVIEW_AGENT_TYPE, DEEP_REVIEW_AGENT_TYPE}; +use crate::agentic::deep_review_policy::{ + CODE_REVIEW_AGENT_TYPE, DEEP_REVIEW_AGENT_TYPE, REVIEW_FIXER_AGENT_TYPE, +}; use crate::agentic::workspace::canonical_local_workspace_path; use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; use bitfun_core_types::{ @@ -614,11 +616,16 @@ fn local_binding(logical_id: &str, runtime_agent_key: &str) -> ExternalSubagentI /// though they are not registered as `Mode` (review child sessions). /// /// Review child sessions are created by the product surfaces with -/// `agentType=CodeReview` (standard) or `agentType=DeepReview` (strict) and -/// must resolve through the primary-agent path for create, turn, restore, and -/// compaction. Other subagents (e.g. `ReviewWorker`) stay restricted. +/// `agentType=CodeReview` (standard) or `agentType=DeepReview` (strict), and +/// the remediation phase of either session runs with `agentType=ReviewFixer`. +/// All three must resolve through the primary-agent path for create, turn, +/// restore, and compaction. Other subagents (e.g. `ReviewWorker`, +/// `ReviewJudge`) stay restricted. fn is_builtin_session_primary_agent(id: &str) -> bool { - matches!(id, CODE_REVIEW_AGENT_TYPE | DEEP_REVIEW_AGENT_TYPE) + matches!( + id, + CODE_REVIEW_AGENT_TYPE | DEEP_REVIEW_AGENT_TYPE | REVIEW_FIXER_AGENT_TYPE + ) } /// Whether a locally-resolved agent entry may act as a session primary agent. diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 4ecdf08fea..8eda392560 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1594,7 +1594,7 @@ fn external_primary_route_follows_the_session_execution_worktree() { fn builtin_review_agents_resolve_as_local_session_primaries() { let registry = AgentRegistry::new(); - for agent_type in ["CodeReview", "DeepReview"] { + for agent_type in ["CodeReview", "DeepReview", "ReviewFixer"] { let binding = registry .resolve_primary_agent_for_turn(agent_type, None, false, None) .unwrap_or_else(|| { @@ -1613,22 +1613,32 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { let registry = AgentRegistry::new(); // Registered subagents that are not session-capable stay restricted. - assert!(registry - .resolve_primary_agent_for_turn("ReviewWorker", None, false, None) - .is_none()); + for agent_type in ["ReviewWorker", "ReviewJudge"] { + assert!( + registry + .resolve_primary_agent_for_turn(agent_type, None, false, None) + .is_none(), + "{agent_type} must not resolve as a session primary agent" + ); + } // Unknown ids remain unknown. assert!(registry .resolve_primary_agent_for_turn("does-not-exist", None, false, None) .is_none()); // The external-owner guard still fails closed for review agents. - assert!(registry - .resolve_primary_agent_for_turn( - "CodeReview", - None, - false, - Some(bitfun_core_types::SessionAgentRouteOwner::External), - ) - .is_none()); + for agent_type in ["CodeReview", "ReviewFixer"] { + assert!( + registry + .resolve_primary_agent_for_turn( + agent_type, + None, + false, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + ) + .is_none(), + "{agent_type} must fail closed for an external owner" + ); + } } #[test] @@ -1641,13 +1651,15 @@ fn local_route_resolves_review_agents_as_session_primaries() { [ ("CodeReview".to_string(), ExternalSubagentRoute::Local), ("DeepReview".to_string(), ExternalSubagentRoute::Local), + ("ReviewFixer".to_string(), ExternalSubagentRoute::Local), ("ReviewWorker".to_string(), ExternalSubagentRoute::Local), + ("ReviewJudge".to_string(), ExternalSubagentRoute::Local), ] .into_iter() .collect(), ); - for agent_type in ["CodeReview", "DeepReview"] { + for agent_type in ["CodeReview", "DeepReview", "ReviewFixer"] { let binding = registry .resolve_primary_agent_for_turn(agent_type, Some(&workspace), true, None) .unwrap_or_else(|| panic!("{agent_type} must resolve through an explicit Local route")); @@ -1659,7 +1671,12 @@ fn local_route_resolves_review_agents_as_session_primaries() { } // Non-session-primary subagents stay restricted even under a Local route. - assert!(registry - .resolve_primary_agent_for_turn("ReviewWorker", Some(&workspace), true, None) - .is_none()); + for agent_type in ["ReviewWorker", "ReviewJudge"] { + assert!( + registry + .resolve_primary_agent_for_turn(agent_type, Some(&workspace), true, None) + .is_none(), + "{agent_type} must not resolve through a Local route" + ); + } } From c4a301e201b7c116eb15d7f795f83fe04ab08a3e Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 6 Aug 2026 23:30:55 +0800 Subject: [PATCH 029/206] test(review): harden ReviewFixer primary resolution guards Follow-up from the review pass on the ReviewFixer session-primary fix: - non_builtin_same_name_review_agent_does_not_resolve_as_session_primary: a non-Builtin entry occupying the builtin ReviewFixer id fails closed instead of inheriting the builtin primary path. - Extend the external-owner guard to DeepReview alongside CodeReview and ReviewFixer. - Document in coordinator is_review_agent_type that ReviewFixer is intentionally excluded from review-phase manifest injection (remediation runs outside DeepReview execution policy gates), and record the primary resolution boundary in deep_review/AGENTS.md. --- .../core/src/agentic/agents/registry/tests.rs | 23 ++++++++++++++++++- .../src/agentic/coordination/coordinator.rs | 11 +++++++++ .../core/src/agentic/deep_review/AGENTS.md | 8 +++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 8eda392560..dfa63a70dc 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1626,7 +1626,7 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { .resolve_primary_agent_for_turn("does-not-exist", None, false, None) .is_none()); // The external-owner guard still fails closed for review agents. - for agent_type in ["CodeReview", "ReviewFixer"] { + for agent_type in ["CodeReview", "DeepReview", "ReviewFixer"] { assert!( registry .resolve_primary_agent_for_turn( @@ -1641,6 +1641,27 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { } } +#[test] +fn non_builtin_same_name_review_agent_does_not_resolve_as_session_primary() { + let registry = AgentRegistry::new(); + + // Custom-agent loading currently filters ids that conflict with builtin + // entries, but the session-primary allowlist is source-gated regardless: + // a non-Builtin entry occupying the builtin "ReviewFixer" id must fail + // closed instead of inheriting the builtin primary path. + registry.write_agents().insert( + "ReviewFixer".to_string(), + test_source_custom_entry("ReviewFixer", "shadow", CustomSubagentKind::User), + ); + + assert!( + registry + .resolve_primary_agent_for_turn("ReviewFixer", None, false, None) + .is_none(), + "a non-Builtin entry named ReviewFixer must not resolve as a session primary agent" + ); +} + #[test] fn local_route_resolves_review_agents_as_session_primaries() { let registry = AgentRegistry::new(); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index aaad99038e..5d2a95fbbc 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -354,6 +354,17 @@ fn resolve_subagent_model_selection( } } +/// Whether a turn belongs to the review phase of a review child session. +/// +/// Only `CodeReview`/`DeepReview` receive the `deep_review_run_manifest` +/// context injection (from turn metadata or persisted session metadata). +/// `ReviewFixer` is intentionally excluded: remediation runs outside the +/// DeepReview execution policy gates (launching it during a review pass is +/// rejected until explicit user approval), and its scope comes from the +/// product-surface remediation prompt rather than the review-phase manifest. +/// Keep this list in sync with the review session primary agents resolved by +/// the agent registry (`is_builtin_session_primary_agent`), i.e. add a new +/// review-phase agent type here, but keep the remediation agent out. fn is_review_agent_type(agent_type: &str) -> bool { matches!( agent_type.to_ascii_lowercase().as_str(), diff --git a/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md b/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md index a89d5d230b..449fdf91a3 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md +++ b/src/crates/assembly/core/src/agentic/deep_review/AGENTS.md @@ -16,6 +16,14 @@ This file applies to DeepReview runtime internals in this directory. reviewer agents in `src/crates/assembly/core/src/agentic/agents`. - Reviewer subagents stay read-only; `ReviewFixer` is not part of the review pass. +- `ReviewFixer` may only act as a session primary in the user-approved + remediation phase of a review child session. The agent registry resolves it + through the builtin primary-agent path without itself checking an approval + flag, so product surfaces must obtain explicit user approval before starting + remediation (mirroring `DeepReviewExecutionPolicy::classify_subagent`, which + rejects `ReviewFixer` during review execution). Do not route `ReviewFixer` + through review-phase manifest injection in the coordinator; its scope is + carried by the remediation prompt. - When queue or report fields change, update the matching frontend DTOs and DeepReview UI state. From 58d89b2d15f3ae10e924d1b4bde28dd09a6fb0fc Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 7 Aug 2026 09:59:19 +0800 Subject: [PATCH 030/206] fix(ci): disable implicit pnpm cache in shell checks --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee3415f43..7324baa1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: uses: actions/setup-node@v5 with: node-version-file: package.json + package-manager-cache: false - name: Reject CRLF in shell and deploy assets run: | From 8450da4fa174304b60c36fd9f4d771cbba79ef8b Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 7 Aug 2026 10:42:52 +0800 Subject: [PATCH 031/206] chore(skills): sync create-bitfun-skin --- .../create-bitfun-skin/SKILL.md | 4 +- .../create-bitfun-skin/agents/openai.yaml | 4 + .../cinematic-animated-wallpaper/SKILL.md | 2 +- .../references/surface-plan.json | 2 +- .../references/appearance-registry.json | 356 +++++++++++++++++- .../references/authoring-workflow.md | 2 + .../tools/implementations/skills/builtin.rs | 6 + 7 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 src/crates/assembly/core/builtin_skills/create-bitfun-skin/agents/openai.yaml diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md index 47a23ce1c4..c3ee710785 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md @@ -39,7 +39,7 @@ A component owns only the Parts returned for that component. Nested visual owner Surface-level states may use an `ancestorPart` selector rooted at `root`. Define the state rule using the registered state id; do not reproduce host selectors in the package. -Important independent owners include `toolbar-mode`, `floating-mini-chat`, `session-menu`, `composer-voice-input`, `miniapp-bubble-welcome`, `session-title-config`, `assistant-card`, `workspace-item`, `external-mcp-overview`, `miniapp-customize-panel`, `user-message-edit-composer`, `voice-input-diagnostics`, `flow-chat-turn-rail`, and `copyable-text-preview`. Query each owner before styling it. +Important independent owners include `toolbar-mode`, `floating-mini-chat`, `session-menu`, `composer-voice-input`, `miniapp-bubble-welcome`, `session-title-config`, `assistant-card`, `workspace-item`, `external-mcp-overview`, `miniapp-customize-panel`, `user-message-edit-composer`, `voice-input-diagnostics`, `flow-chat-turn-rail`, `copyable-text-preview`, `reasoning-preset-selector`, `reasoning-config-panel`, `reasoning-preset-editor`, `market-account-controls`, and `miniapp-market-view`. Query each owner before styling it. This list is a navigation aid, not an exhaustive contract; the registry is authoritative. The following Parts are not registered and must not be used: @@ -67,7 +67,7 @@ Currently bundled: | Example | Load when | Do not inherit blindly | | --- | --- | --- | -| [cinematic-animated-wallpaper](examples/cinematic-animated-wallpaper/SKILL.md) | Animated character artwork, source-derived glass materials, image-led cards, or illustrated dialogs | Its asset roles, crop defaults, palette, and 43-component/7-scene surface selection | +| [cinematic-animated-wallpaper](examples/cinematic-animated-wallpaper/SKILL.md) | Animated character artwork, source-derived glass materials, image-led cards, or illustrated dialogs | Its asset roles, crop defaults, palette, and selected surface plan | Read [style-example-contract.md](references/style-example-contract.md) before adding or restructuring an example. An example is a design recipe validated against the registry, not a second contract snapshot. diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/agents/openai.yaml b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/agents/openai.yaml new file mode 100644 index 0000000000..a815603114 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "BitFun Appearance Manual" + short_description: "Author and validate contract-safe BitFun skins" + default_prompt: "Use $create-bitfun-skin to inspect the current Appearance contract, choose a relevant style example, and author or repair a valid sparse skin." diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/SKILL.md b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/SKILL.md index 97e0289649..a7f0a6c486 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/SKILL.md @@ -7,7 +7,7 @@ description: Apply the cinematic animated-wallpaper style recipe to a BitFun App Load the parent `create-bitfun-skin` Skill first. The parent registry and package references define what can be changed; this example defines one optional visual strategy. -Do not reuse this example's 43-component/7-scene coverage, asset roles, crop defaults, palette, opacity, or material choices unless they fit the user's artwork and intent. Its [surface-plan.json](references/surface-plan.json) is an example style selection validated against one registry revision, not a list of all current Appearance surfaces. +Do not reuse this example's asset roles, crop defaults, palette, opacity, or material choices unless they fit the user's artwork and intent. Its [surface-plan.json](references/surface-plan.json) is an example style selection validated against one registry revision, not a list of all current Appearance surfaces. Re-query the parent registry whenever the host checkout or bundled snapshot changes. Read [style-playbook.md](references/style-playbook.md) for visual decisions and [palette-contract.md](references/palette-contract.md) before creating a custom palette. Read the parent [media-quality-policy.md](../../references/media-quality-policy.md) before overriding video or WebP quality. diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json index 34ce91c8c6..358c341f6b 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "scope": "example-style-selection", "styleId": "cinematic-animated-wallpaper", - "validatedAgainstRegistryRevision": "7fdf35b7be1c2693e33caae122dc970fb953865b", + "validatedAgainstRegistryRevision": "71fbdbb26757c930eedfef5cfda55ca12e008ae1", "scenes": { "workbench": { "parts": { diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json index 35f2ff68ee..75b344b3b5 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json @@ -1,9 +1,9 @@ { "schema": "bitfun.appearance.registry", "schemaVersion": 1, - "generatedFrom": "7fdf35b7be1c2693e33caae122dc970fb953865b", - "generatedAt": "2026-08-03T01:06:32.137402+00:00", - "sourceRevision": "7fdf35b7be1c2693e33caae122dc970fb953865b", + "generatedFrom": "71fbdbb26757c930eedfef5cfda55ca12e008ae1", + "generatedAt": "2026-08-06T15:48:31.702263+00:00", + "sourceRevision": "71fbdbb26757c930eedfef5cfda55ca12e008ae1", "sourceDirty": false, "sourceTreeHash": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", "components": [ @@ -3022,6 +3022,52 @@ } ] }, + { + "id": "reasoning-preset-selector", + "parts": [ + { + "id": "root", + "propertyProfile": "container" + }, + { + "id": "trigger", + "propertyProfile": "container" + }, + { + "id": "label", + "propertyProfile": "container" + }, + { + "id": "menu", + "propertyProfile": "container" + }, + { + "id": "header", + "propertyProfile": "container" + }, + { + "id": "option", + "propertyProfile": "container" + } + ], + "facets": [], + "states": [ + { + "id": "open", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"open\"]" + } + }, + { + "id": "selected", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"selected\"]" + } + } + ] + }, { "id": "flow-chat-header", "parts": [ @@ -5485,6 +5531,110 @@ { "id": "packageDiagnosticAllowedParts", "propertyProfile": "container" + }, + { + "id": "packageMissingSelection", + "propertyProfile": "container" + }, + { + "id": "marketDialog", + "propertyProfile": "container" + }, + { + "id": "marketToolbar", + "propertyProfile": "container" + }, + { + "id": "marketGrid", + "propertyProfile": "container" + }, + { + "id": "marketCard", + "propertyProfile": "container" + }, + { + "id": "marketPreview", + "propertyProfile": "container" + }, + { + "id": "marketCardBody", + "propertyProfile": "container" + }, + { + "id": "marketStatus", + "propertyProfile": "container" + }, + { + "id": "marketEmpty", + "propertyProfile": "container" + }, + { + "id": "marketError", + "propertyProfile": "container" + }, + { + "id": "marketDetail", + "propertyProfile": "container" + }, + { + "id": "marketDetailPreview", + "propertyProfile": "container" + }, + { + "id": "marketDetailBody", + "propertyProfile": "container" + }, + { + "id": "marketWarning", + "propertyProfile": "container" + }, + { + "id": "marketReleaseList", + "propertyProfile": "container" + }, + { + "id": "marketRelease", + "propertyProfile": "container" + }, + { + "id": "marketActions", + "propertyProfile": "container" + }, + { + "id": "marketNav", + "propertyProfile": "container" + }, + { + "id": "marketWorkflow", + "propertyProfile": "container" + }, + { + "id": "marketManualSubmit", + "propertyProfile": "container" + }, + { + "id": "marketSubmissionList", + "propertyProfile": "container" + }, + { + "id": "marketSubmission", + "propertyProfile": "container" + }, + { + "id": "marketReviewLayout", + "propertyProfile": "container" + }, + { + "id": "marketReviewQueue", + "propertyProfile": "container" + }, + { + "id": "marketReviewDetail", + "propertyProfile": "container" + }, + { + "id": "marketReviewActions", + "propertyProfile": "container" } ], "facets": [ @@ -7940,6 +8090,78 @@ } ] }, + { + "id": "market-account-controls", + "parts": [ + { + "id": "root", + "propertyProfile": "container" + }, + { + "id": "identityTrigger", + "propertyProfile": "container" + }, + { + "id": "menu", + "propertyProfile": "container" + }, + { + "id": "profile", + "propertyProfile": "container" + }, + { + "id": "menuItem", + "propertyProfile": "container" + }, + { + "id": "login", + "propertyProfile": "container" + }, + { + "id": "waiting", + "propertyProfile": "container" + }, + { + "id": "error", + "propertyProfile": "container" + }, + { + "id": "actions", + "propertyProfile": "container" + } + ], + "facets": [], + "states": [ + { + "id": "loading", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"loading\"]" + } + }, + { + "id": "signedOut", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"signed-out\"]" + } + }, + { + "id": "signedIn", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"signed-in\"]" + } + }, + { + "id": "authorizing", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"authorizing\"]" + } + } + ] + }, { "id": "ssh-remote", "parts": [ @@ -8271,6 +8493,16 @@ "propertyProfile": "container", "visualRole": "content" }, + { + "id": "assistantSessionActions", + "propertyProfile": "control", + "visualRole": "control" + }, + { + "id": "assistantSessionMenu", + "propertyProfile": "overlay", + "visualRole": "popup" + }, { "id": "bottomBar", "propertyProfile": "container", @@ -9577,11 +9809,19 @@ "propertyProfile": "container" }, { - "id": "providerGrid", + "id": "providerSearch", + "propertyProfile": "container" + }, + { + "id": "providerList", "propertyProfile": "container" }, { - "id": "providerCard", + "id": "providerRow", + "propertyProfile": "container" + }, + { + "id": "providerSelect", "propertyProfile": "container" }, { @@ -9593,11 +9833,11 @@ "propertyProfile": "container" }, { - "id": "providerModels", + "id": "providerEmpty", "propertyProfile": "container" }, { - "id": "providerTag", + "id": "providerMore", "propertyProfile": "container" }, { @@ -9737,6 +9977,104 @@ } ] }, + { + "id": "reasoning-config-panel", + "parts": [ + { + "id": "root", + "propertyProfile": "container" + }, + { + "id": "body", + "propertyProfile": "container" + }, + { + "id": "footer", + "propertyProfile": "container" + }, + { + "id": "error", + "propertyProfile": "container" + }, + { + "id": "actions", + "propertyProfile": "container" + } + ], + "facets": [], + "states": [] + }, + { + "id": "reasoning-preset-editor", + "parts": [ + { + "id": "root", + "propertyProfile": "container" + }, + { + "id": "section", + "propertyProfile": "container" + }, + { + "id": "primarySettings", + "propertyProfile": "container" + }, + { + "id": "binding", + "propertyProfile": "container" + }, + { + "id": "generated", + "propertyProfile": "container" + }, + { + "id": "header", + "propertyProfile": "container" + }, + { + "id": "empty", + "propertyProfile": "container" + }, + { + "id": "list", + "propertyProfile": "container" + }, + { + "id": "preset", + "propertyProfile": "container" + }, + { + "id": "presetSummary", + "propertyProfile": "container" + }, + { + "id": "presetEditor", + "propertyProfile": "container" + }, + { + "id": "actions", + "propertyProfile": "container" + }, + { + "id": "action", + "propertyProfile": "container" + }, + { + "id": "actionControls", + "propertyProfile": "container" + } + ], + "facets": [], + "states": [ + { + "id": "expanded", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"expanded\"]" + } + } + ] + }, { "id": "external-sources-config", "parts": [ @@ -10408,6 +10746,10 @@ "id": "newSession", "propertyProfile": "container" }, + { + "id": "setPrimary", + "propertyProfile": "container" + }, { "id": "delete", "propertyProfile": "container" diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/authoring-workflow.md b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/authoring-workflow.md index 8304f34db6..29a06e6f7c 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/authoring-workflow.md +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/authoring-workflow.md @@ -43,6 +43,8 @@ When a checkout is available, first check registry synchronization and then run For generated skins, record source paths and hashes, style input hashes, build parameters, output hashes, registry provenance, host verification, and runtime inspection state. Rebuild from the record and provide a read-only drift check. +Treat the manifest `version` as the release identity of the importable package. Bump it whenever the manifest, declared assets, renderer settings, selected surfaces, or host-compatibility migration changes. Use a patch bump for compatible corrections and a minor bump for new visual coverage or capabilities. Preserve the version only for a byte-equivalent rebuild of the same package content or when changing reports and other files outside `package/`. + Keep reports, source files, and contact sheets outside the importable `package/` directory. ## 7. Inspect at runtime diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs index 6b5bf38af5..0f07477f6a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/builtin.rs @@ -554,6 +554,12 @@ mod tests { "create-bitfun-skin/examples/cinematic-animated-wallpaper/SKILL.md", ); assert!(example.contains("cinematic animated-wallpaper")); + + let metadata = embedded_skill_text("create-bitfun-skin/agents/openai.yaml"); + assert!(metadata.contains("display_name: \"BitFun Appearance Manual\"")); + + let workflow = embedded_skill_text("create-bitfun-skin/references/authoring-workflow.md"); + assert!(workflow.contains("Bump it whenever the manifest")); } #[test] From 5bfd15a1608374438b76bcbe6e9758778e1113b9 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 19:38:36 -0700 Subject: [PATCH 032/206] feat(skin-market): show submitter GitHub account in review The admin review page had no way to tell which GitHub account sent a submission. Expose the submission owner on the admin detail response and render it as a linked avatar in the review facts. --- .../product-domains/src/appearance_market.rs | 3 ++ .../services/skin-market-service/src/lib.rs | 2 ++ .../skin-market-service/src/routes.rs | 21 ++++++++++++-- src/skin-market-web/src/AdminPage.tsx | 16 +++++++++++ src/skin-market-web/src/i18n.ts | 4 +++ src/skin-market-web/src/styles.css | 28 +++++++++++++++++++ src/skin-market-web/src/types.ts | 1 + 7 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/crates/contracts/product-domains/src/appearance_market.rs b/src/crates/contracts/product-domains/src/appearance_market.rs index ec9f4ae39f..2c6673fa73 100644 --- a/src/crates/contracts/product-domains/src/appearance_market.rs +++ b/src/crates/contracts/product-domains/src/appearance_market.rs @@ -196,6 +196,9 @@ pub struct AppearanceMarketSubmissionDraftRequest { #[serde(rename_all = "camelCase")] pub struct AppearanceAdminSubmissionDetail { pub submission: AppearanceMarketSubmission, + /// GitHub account that owns the submission, so reviewers can see who sent it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submitter: Option, #[serde(skip_serializing_if = "Option::is_none")] pub manifest: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/crates/services/skin-market-service/src/lib.rs b/src/crates/services/skin-market-service/src/lib.rs index 7ef4cb8198..238731bc74 100644 --- a/src/crates/services/skin-market-service/src/lib.rs +++ b/src/crates/services/skin-market-service/src/lib.rs @@ -436,6 +436,8 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let approved = json_body(response).await; assert_eq!(approved["submission"]["status"], "approved"); + assert_eq!(approved["submitter"]["login"], "owner"); + assert_eq!(approved["submitter"]["githubId"], 41); assert!(approved["reviewBundleHash"].as_str().is_some()); let response = app diff --git a/src/crates/services/skin-market-service/src/routes.rs b/src/crates/services/skin-market-service/src/routes.rs index 241b30916a..0ab70def5a 100644 --- a/src/crates/services/skin-market-service/src/routes.rs +++ b/src/crates/services/skin-market-service/src/routes.rs @@ -1521,8 +1521,11 @@ async fn admin_submission_detail( submission_id: &str, ) -> SkinMarketResult { let row = sqlx::query( - "SELECT manifest_json, package_sha256, preview_sha256, draft_json, package_meta_json - FROM submissions WHERE id = ?", + "SELECT s.manifest_json, s.package_sha256, s.preview_sha256, s.draft_json, + s.package_meta_json, u.github_id, u.login, u.avatar_url + FROM submissions s + LEFT JOIN users u ON u.id = s.owner_user_id + WHERE s.id = ?", ) .bind(submission_id) .fetch_optional(state.database.pool()) @@ -1559,8 +1562,22 @@ async fn admin_submission_detail( } _ => None, }; + let submitter = row + .try_get::, _>("github_id") + .map_err(SkinMarketError::internal)? + .map(|github_id| { + Ok::<_, SkinMarketError>(AppearanceMarketUserSummary { + github_id, + login: row.try_get("login").map_err(SkinMarketError::internal)?, + avatar_url: row + .try_get("avatar_url") + .map_err(SkinMarketError::internal)?, + }) + }) + .transpose()?; Ok(AppearanceAdminSubmissionDetail { submission: submission_by_id(state, submission_id, None).await?, + submitter, manifest: manifest_json .map(|value| parse_json(value, "appearance manifest")) .transpose()?, diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx index 396448fb88..596348be4b 100644 --- a/src/skin-market-web/src/AdminPage.tsx +++ b/src/skin-market-web/src/AdminPage.tsx @@ -173,6 +173,22 @@ export function AdminPage({ account, accountResolved, locale, t }: AdminPageProp )}
+
+
{t('reviewSubmitter')}
+
+ {detail.submitter ? ( + + + @{detail.submitter.login} + + ) : t('reviewSubmitterUnknown')} +
+
{t('packageIdentity')}
{detail.submission.packageId || t('notDeclared')}
{t('version')}
{detail.submission.packageVersion || t('notDeclared')}
{t('compatibility')}
{detail.submission.minBitfunVersion}
diff --git a/src/skin-market-web/src/i18n.ts b/src/skin-market-web/src/i18n.ts index ccafaf083d..5334553208 100644 --- a/src/skin-market-web/src/i18n.ts +++ b/src/skin-market-web/src/i18n.ts @@ -118,6 +118,8 @@ const messages = { reviewEmptyBody: 'There are no submitted appearance packages waiting for review.', reviewQueueLabel: 'Submissions awaiting review', reviewDetailLoading: 'Loading submission details…', + reviewSubmitter: 'Submitted by', + reviewSubmitterUnknown: 'Unknown account', reviewPackageHash: 'Package SHA-256', reviewPreviewHash: 'Preview SHA-256', reviewBundleHash: 'Review bundle hash', @@ -249,6 +251,8 @@ const messages = { reviewEmptyBody: '当前没有等待审核的外观包投稿。', reviewQueueLabel: '待审核投稿', reviewDetailLoading: '正在加载投稿详情…', + reviewSubmitter: '投稿账号', + reviewSubmitterUnknown: '未知账号', reviewPackageHash: '包 SHA-256', reviewPreviewHash: '预览图 SHA-256', reviewBundleHash: '审核包哈希', diff --git a/src/skin-market-web/src/styles.css b/src/skin-market-web/src/styles.css index 6212f62145..f72045e969 100644 --- a/src/skin-market-web/src/styles.css +++ b/src/skin-market-web/src/styles.css @@ -1790,6 +1790,34 @@ img { white-space: nowrap; } +.review-submitter { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; + color: inherit; + text-decoration: none; +} + +.review-submitter:hover span { + text-decoration: underline; +} + +.review-submitter img { + width: 18px; + height: 18px; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 50%; + object-fit: cover; +} + +.review-submitter span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .review-section, .hash-list, .manifest-panel, diff --git a/src/skin-market-web/src/types.ts b/src/skin-market-web/src/types.ts index c8d389ed0c..aa536f5489 100644 --- a/src/skin-market-web/src/types.ts +++ b/src/skin-market-web/src/types.ts @@ -97,6 +97,7 @@ export interface AppearanceSubmission { export interface AppearanceAdminSubmissionDetail { submission: AppearanceSubmission; + submitter?: AppearanceMarketUser; manifest?: unknown; packageSha256?: string; previewSha256?: string; From 5733f25c5a03f98a63378f8d00b2eeb6e112eae6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 19:50:23 -0700 Subject: [PATCH 033/206] fix(release): move the mirror out of the website dist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release mirror lived in BitFun-Website/dist/release. `npm run build` empties dist/, so every website deploy deleted the mirrored installers and manifests — downloads and the updater fallback 404'd until the next cron sync re-downloaded ~2.3 GB from GitHub. Point WEBSITE_RELEASE_DIR at /srv/bitfun-release instead, outside any build output, and let the environment override it. nginx serves that directory through a `location ^~ /release/` alias, so the public URLs are unchanged. --- scripts/openbitfun-release-sync.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index b29f3fcade..fb9fb0426a 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -46,7 +46,12 @@ GITHUB_LATEST_JSON_URL="https://github.com/GCWing/BitFun/releases/latest/downloa GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json" GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/download/relay-image.json" OPENBITFUN_BASE_URL="https://openbitfun.com/release" -WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" +# The mirror deliberately lives outside the website checkout. It used to be +# BitFun-Website/dist/release, but `npm run build` empties dist/, so every +# website deploy silently deleted the mirrored installers and manifests — +# breaking downloads and the updater fallback until someone noticed. nginx +# serves this directory through a `location ^~ /release/` alias instead. +WEBSITE_RELEASE_DIR="${WEBSITE_RELEASE_DIR:-/srv/bitfun-release}" LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" From 353d7ff8910609ec1b200e620bc7c4eb42904e57 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 11:19:09 +0800 Subject: [PATCH 034/206] fix(web-ui): prevent file mention picker positioning regression - Reset inherited `bottom` and `left` styles for portalled mention overlays. - Let anchored viewport positioning control the picker placement. - Verify with `FileMentionPickerOverlay.test.tsx`. --- src/web-ui/src/flow_chat/components/FileMentionPicker.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss index 60306347de..e68f002e83 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss @@ -33,6 +33,10 @@ &--overlay { position: fixed; + // The non-portalled picker is positioned with `bottom`; clear that + // constraint when the overlay is placed by the viewport anchor hook. + bottom: auto; + left: auto; z-index: $z-popover; width: min(400px, calc(100vw - 16px)); min-width: min(260px, calc(100vw - 16px)); From e03d7cf31c3e6a0e9fda829f774e1d8e7f0fb24d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 20:06:53 -0700 Subject: [PATCH 035/206] fix(skin-market): stop the market dialog from resizing while loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening 浏览市场 flashed the empty state, swapped in a short "loading" line, then grew the dialog once the cards arrived — three height changes in a row, which read as a shake. The dialog now keeps one height: the modal content is a fixed-height flex column and each view (browse grid, detail, submissions) scrolls internally instead of stretching the dialog. While the first page is in flight the grid holds placeholder cards of the real card size, so the loaded state drops straight in; a refresh keeps the current cards mounted and only dims them, and 加载更多 keeps its row in place with a loading button. The loading text moves to an sr-only live region. --- .../config/components/AppearanceConfig.scss | 105 ++++++++- .../AppearanceMarketDialog.test.tsx | 38 ++++ .../components/AppearanceMarketDialog.tsx | 213 +++++++++++------- .../components/AppearanceMarketWorkflows.tsx | 128 +++++------ 4 files changed, 337 insertions(+), 147 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss index ab07b183b5..b76daa043b 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss @@ -420,19 +420,22 @@ } } +/* Fixed height: every view scrolls internally so the dialog never resizes while loading. */ .appearance-market__modal { min-height: min(720px, 78vh); + max-height: min(720px, 78vh); } .appearance-market { display: flex; flex: 1; flex-direction: column; - min-height: min(620px, 68vh); + min-height: 0; color: var(--bf-appearance-token-color-text-primary); &__nav { display: flex; + flex: 0 0 auto; gap: $size-gap-1; margin: 0 0 $size-gap-4; padding-bottom: $size-gap-2; @@ -468,8 +471,18 @@ flex-direction: column; } + &__workflow-body { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow-y: auto; + padding-right: $size-gap-1; + } + &__workflow-heading { display: flex; + flex: 0 0 auto; align-items: flex-start; justify-content: space-between; gap: $size-gap-3; @@ -543,9 +556,8 @@ &__submission-list { display: grid; + align-content: start; gap: $size-gap-2; - overflow-y: auto; - padding-right: $size-gap-1; } @media (max-width: 760px) { @@ -805,8 +817,16 @@ } } + &__browse { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + } + &__toolbar { display: grid; + flex: 0 0 auto; grid-template-columns: minmax(240px, 1fr) 150px 160px; gap: $size-gap-2; align-items: center; @@ -819,12 +839,24 @@ } } + &__results { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 1px $size-gap-1 $size-gap-2 1px; + transition: opacity $motion-fast $easing-standard; + + /* A refresh keeps the current cards in place and dims them — no reflow. */ + &--dimmed { + opacity: 0.55; + pointer-events: none; + } + } + &__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: $size-gap-3; - overflow-y: auto; - padding: 1px $size-gap-1 $size-gap-2 1px; } &__card { @@ -860,6 +892,51 @@ cursor: default; opacity: $opacity-disabled; } + + &--skeleton { + cursor: default; + pointer-events: none; + } + } + + &__skeleton-line { + position: relative; + display: block; + width: 100%; + height: 11px; + margin-top: $size-gap-1; + overflow: hidden; + border-radius: $size-radius-sm; + background: var(--bf-appearance-token-color-overlay-white-08); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0) 0%, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0.16) 48%, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0) 100% + ); + transform: translateX(-100%); + animation: appearance-market-skeleton-shimmer 1.3s $easing-standard infinite; + } + + &--title { + width: 68%; + height: 14px; + margin-top: 0; + } + + &--meta { + width: 45%; + height: 10px; + } + + &--short { + width: 76%; + } } &__preview { @@ -1004,9 +1081,14 @@ &__detail { display: grid; + flex: 1; grid-template-columns: minmax(260px, 36%) minmax(0, 1fr); gap: $size-gap-5; align-items: start; + align-content: start; + min-height: 0; + overflow-y: auto; + padding-right: $size-gap-1; > .btn:first-child { grid-column: 1 / -1; @@ -1215,6 +1297,19 @@ } +@keyframes appearance-market-skeleton-shimmer { + 100% { + transform: translateX(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .appearance-market__skeleton-line::after { + animation: none; + } +} + + .appearance-card { position: relative; display: flex; diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 4610574b70..606b94e2b0 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -248,6 +248,44 @@ describe('AppearanceMarketDialog', () => { expect(container.textContent).toContain('package.market.noAutoApply'); }); + it('holds the grid with placeholder cards while the first page loads', async () => { + let resolveBrowse: (page: unknown) => void = () => undefined; + mocks.browse.mockImplementation(() => new Promise(resolve => { + resolveBrowse = resolve; + })); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + + // Placeholder cards stand in for the real ones so the dialog keeps one size, + // and the empty state never flashes before the first page resolves. + expect(container.querySelectorAll('.appearance-market__card--skeleton').length) + .toBeGreaterThan(0); + expect(container.textContent).not.toContain('package.market.empty'); + + await act(async () => { + resolveBrowse({ items: [summary] }); + await Promise.resolve(); + }); + + await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night')); + expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); + }); + + it('keeps an empty result set on the empty state once loading settles', async () => { + mocks.browse.mockResolvedValue({ items: [] }); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + + await vi.waitFor(() => expect(container.textContent).toContain('package.market.empty')); + expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); + }); + it('shows the shared-account submissions and admin review workflows', async () => { const submission = { submissionId: 'submission-1', diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index 8141960ce3..3d369be24a 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -47,6 +47,9 @@ const SUPPORTED_CAPABILITIES = new Set([ 'background-media.v1', ]); +/** Placeholder cards keep the grid at its loaded height so the dialog never resizes mid-load. */ +const SKELETON_CARD_COUNT = 6; + interface AppearanceMarketDialogProps { isOpen: boolean; onClose: () => void; @@ -114,6 +117,8 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial const [nextCursor, setNextCursor] = useState(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); + const [appending, setAppending] = useState(false); + const [loadedOnce, setLoadedOnce] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [installing, setInstalling] = useState(false); const [error, setError] = useState(null); @@ -130,6 +135,7 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial const loadPage = useCallback(async (cursor?: string, append = false) => { const sequence = ++browseSequence.current; setLoading(true); + setAppending(append); setError(null); try { const page = await appearanceMarketAPI.browse({ ...browseRequest, cursor }); @@ -141,7 +147,11 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial setError(errorMessage(loadError)); if (!append) setItems([]); } finally { - if (sequence === browseSequence.current) setLoading(false); + if (sequence === browseSequence.current) { + setLoading(false); + setAppending(false); + setLoadedOnce(true); + } } }, [browseRequest]); @@ -428,6 +438,12 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial ); }; + // Placeholder cards stand in until the first page lands; a refresh keeps the + // current cards mounted and merely dims them. Both keep the dialog one size. + const showSkeletons = !loadedOnce || (loading && !appending && items.length === 0); + const refreshing = loading && !appending && items.length > 0; + const showEmpty = loadedOnce && !loading && items.length === 0 && !error; + return ( } size="xlarge" contentInset - contentClassName="appearance-market__modal" + contentClassName="modal__content--fill-flex appearance-market__modal" testId="appearance-market-dialog" >
{view !== 'browse' ? : detail ? renderDetail() : ( - <> +
- {items.map(item => { - const local = installedEntry(appearances, item); - const updateAvailable = Boolean( - local?.marketOrigin?.listingId === item.listingId - && item.latestRelease > local.marketOrigin.releaseNumber, - ); - return ( - - ); - })} +
+ {item.previewUrl + ? ( + retryOriginalMarketImage(event.currentTarget, item.previewUrl)} + /> + ) + : } + {t(`package.market.mode.${item.mode}`)} +
+
+ {item.name} + {item.author || item.owner.login} · v{item.packageVersion} +

{item.description}

+
+ {local && ( + + {updateAvailable + ? t('package.market.updateAvailable') + : local.localOverride + ? t('package.market.modified') + : t('package.market.installed')} + + )} + + ); + })} +
+ )} + + {showEmpty && ( +
+
+ )} + + {nextCursor && !showSkeletons && ( +
+ +
+ )}
- {!loading && items.length === 0 && !error && ( -
-
- )} - {loading &&

{t('package.market.loading')}

} - {nextCursor && !loading && ( -
- -
- )} - + {loading && {t('package.market.loading')}} +
)}
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx index dbd33fcdb0..47ef8ed38e 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx @@ -390,71 +390,73 @@ export function AppearanceMarketWorkflows({ workflow }: AppearanceMarketWorkflow
{renderError()} - {renderManualSubmit()} - {loading ?

{t('package.market.submissions.loading')}

- : submissions.length === 0 ? ( -
-
- ) : ( -
- {submissions.map(submission => ( -
-
- {submission.previewUrl - ? ( - retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} - /> - ) - : } -
-
-
- {submission.name || submission.slug} - - {t(`package.market.submissions.status.${submissionDisplayStatus(submission)}`)} - +
+ {renderManualSubmit()} + {loading ?

{t('package.market.submissions.loading')}

+ : submissions.length === 0 ? ( +
+
+ ) : ( +
+ {submissions.map(submission => ( +
+
+ {submission.previewUrl + ? ( + retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} + /> + ) + : } +
+
+
+ {submission.name || submission.slug} + + {t(`package.market.submissions.status.${submissionDisplayStatus(submission)}`)} + +
+

{submission.description || submission.slug}

+ + {submission.packageVersion ? `v${submission.packageVersion} · ` : ''} + {t('package.market.submissions.updated', { date: formattedDate(submission.updatedAt) })} + + {submission.rejectionReason && ( +

+ {t('package.market.submissions.rejection', { reason: submission.rejectionReason })} +

+ )}
-

{submission.description || submission.slug}

- - {submission.packageVersion ? `v${submission.packageVersion} · ` : ''} - {t('package.market.submissions.updated', { date: formattedDate(submission.updatedAt) })} - - {submission.rejectionReason && ( -

- {t('package.market.submissions.rejection', { reason: submission.rejectionReason })} -

+ {canWithdraw(submission) && ( + )} -
- {canWithdraw(submission) && ( - - )} -
- ))} -
- )} + + ))} +
+ )} +
); } From af0c906763915f4e41e76f622f57a2badd1be18c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 20:13:39 -0700 Subject: [PATCH 036/206] fix(skin-market): register the new market parts in the Appearance contract marketBrowse, marketResults and the loading state are new data-bf hooks; the contract audit rejects any part or state a Skin cannot target. --- .../config/components/AppearanceConfig.appearance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts index 0c85a628cd..6ab27a1f39 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts @@ -13,7 +13,8 @@ export const appearanceConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'packageDiagnosticsGroup' }, { id: 'packageDiagnosticIssue' }, { id: 'packageDiagnosticAllowedParts' }, { id: 'packageMissingSelection' }, - { id: 'marketDialog' }, { id: 'marketToolbar' }, { id: 'marketGrid' }, + { id: 'marketDialog' }, { id: 'marketToolbar' }, { id: 'marketBrowse' }, + { id: 'marketResults' }, { id: 'marketGrid' }, { id: 'marketCard' }, { id: 'marketPreview' }, { id: 'marketCardBody' }, { id: 'marketStatus' }, { id: 'marketEmpty' }, { id: 'marketError' }, { id: 'marketDetail' }, { id: 'marketDetailPreview' }, { id: 'marketDetailBody' }, @@ -31,5 +32,6 @@ export const appearanceConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, ], }; From 132717fd2a3b17f4105b3cadd0a639649b015ba7 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 02:40:05 +0800 Subject: [PATCH 037/206] fix(config): recover startup from incompatible model settings - Make model normalization and validation capability-aware so speech, image, and embedding models do not inherit text-generation constraints. - Prevent pure speech model sentinels such as context_window=0 and max_tokens=0 from aborting application startup. - Isolate recoverable invalid models, reconcile model references, and preserve structured diagnostics. - Add schema versioning, pre-repair backups, default recovery for malformed configuration, and strict atomic persistence. - Save cloud speech model, speech default, and voice-input settings in one atomic operation. - Expose the new configuration APIs across Desktop, App Server, WebSocket, and Web UI. - Add the reusable bitfun-config-loader and focused regression tests. --- Cargo.lock | 1 + scripts/check-core-boundaries.test.mjs | 9 + .../cargo-dependency-boundaries.mjs | 1 + .../core-boundaries/rules/feature-rules.mjs | 18 +- .../rules/source/required-rules.mjs | 4 +- scripts/core-boundaries/self-test.mjs | 4 +- src/apps/desktop/src/api/config_api.rs | 26 + .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/lib.rs | 42 ++ src/crates/assembly/core/Cargo.toml | 2 +- .../core/src/service/config/manager.rs | 324 ++++++---- .../assembly/core/src/service/config/mod.rs | 6 + .../core/src/service/config/normalization.rs | 514 ++++++++++++++++ .../core/src/service/config/providers.rs | 242 +++++++- .../core/src/service/config/service.rs | 568 +++++++++++------- .../assembly/core/src/service/config/types.rs | 105 +++- .../assembly/core/src/util/types/config.rs | 22 +- .../interfaces/app-server-client/Cargo.toml | 1 + .../interfaces/app-server-client/src/lib.rs | 24 + .../app-server-protocol/src/config.rs | 72 +++ .../interfaces/app-server-protocol/src/lib.rs | 1 + .../app-server/src/schema/config.rs | 5 + .../app-server/src/server/handlers/app.rs | 2 + .../app-server/src/server/handlers/config.rs | 44 ++ src/crates/services/services-core/AGENTS.md | 4 +- src/crates/services/services-core/Cargo.toml | 8 + src/crates/services/services-core/src/lib.rs | 2 +- src/web-ui/src/app/App.tsx | 48 ++ .../api/adapters/websocket-adapter.test.ts | 9 +- .../api/adapters/websocket-adapter.ts | 2 + .../api/service-api/ConfigAPI.test.ts | 24 + .../api/service-api/ConfigAPI.ts | 28 + .../config/components/VoiceInputConfig.tsx | 53 +- .../config/services/ConfigManager.ts | 21 +- .../src/infrastructure/config/types/index.ts | 9 + .../src/locales/en-US/settings/basics.json | 4 + .../src/locales/zh-CN/settings/basics.json | 4 + .../src/locales/zh-TW/settings/basics.json | 4 + 38 files changed, 1830 insertions(+), 431 deletions(-) create mode 100644 src/crates/assembly/core/src/service/config/normalization.rs create mode 100644 src/crates/interfaces/app-server-protocol/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 873fa7956f..8a9022b5fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,6 +926,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "bitfun-app-server-protocol", + "serde_json", "tokio", ] diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 0a8d2f133e..62c59a7689 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1853,6 +1853,14 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:sha2', 'tokio/fs', ]); + assert.deepEqual(profiles.get('json-io'), [ + 'dep:fs2', + 'dep:windows', + 'tokio/fs', + 'tokio/sync', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', + ]); assert.deepEqual(profiles.get('local-storage'), [ 'dep:bitfun-core-types', 'dep:bitfun-events', @@ -1967,6 +1975,7 @@ test('services-core Tokio capabilities stay owner-scoped', () => { ], features: { filesystem: [], + 'json-io': [], 'local-storage': [], 'process-runtime': [], 'workspace-instructions': [], diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 02d67bf762..0c00d7ca84 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -147,6 +147,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ const SERVICES_CORE_TOKIO_FEATURES = new Map([ ['filesystem', ['fs']], + ['json-io', ['fs', 'sync']], ['local-storage', ['fs', 'sync']], ['process-runtime', ['io-util', 'process']], ['workspace-instructions', ['fs', 'io-util']], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index c6ba62c96a..1e0cdc078d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -29,7 +29,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime'] }, { depName: 'chrono', ownerFeatures: ['filesystem', 'local-storage'] }, { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, - { depName: 'fs2', ownerFeatures: ['local-storage', 'runtime-ownership'] }, + { depName: 'fs2', ownerFeatures: ['json-io', 'local-storage', 'runtime-ownership'] }, { depName: 'git2', ownerFeatures: ['session-git'] }, { depName: 'globset', ownerFeatures: ['workspace-instructions'] }, { depName: 'ignore', ownerFeatures: ['filesystem'] }, @@ -49,7 +49,7 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'which', ownerFeatures: ['process-runtime'] }, { depName: 'win32job', ownerFeatures: ['process-runtime'] }, - { depName: 'windows', ownerFeatures: ['local-storage', 'process-runtime'] }, + { depName: 'windows', ownerFeatures: ['json-io', 'local-storage', 'process-runtime'] }, { depName: 'zip', ownerFeatures: ['lsp'] }, ], }, @@ -396,6 +396,20 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core filesystem must own only local file operations and recursive search dependencies', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'json-io', + requiredFeatureRefs: [ + 'dep:fs2', + 'dep:windows', + 'tokio/fs', + 'tokio/sync', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', + ], + exact: true, + reason: 'services-core json-io must own only generic locked and atomic JSON file IO', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'local-storage', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 73447c3e3c..983c01ed1b 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -43,8 +43,8 @@ export const requiredContentRules = [ message: 'missing filesystem capability source gate', }, { - regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod json_store;/, - message: 'missing local-storage JSON owner source gate', + regex: /#\[cfg\(any\(feature = "json-io", feature = "local-storage"\)\)\]\s*pub mod json_store;/, + message: 'missing json-io/local-storage JSON owner source gate', }, { regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod persistence;/, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 782ac5d1f7..7d9b3d37cf 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -937,7 +937,7 @@ export function runManifestParserSelfTest({ ['bitfun-core-types', ['local-storage', 'lsp']], ['bitfun-events', ['local-storage']], ['chrono', ['filesystem', 'local-storage']], - ['fs2', ['local-storage', 'runtime-ownership']], + ['fs2', ['json-io', 'local-storage', 'runtime-ownership']], ['git2', ['session-git']], ['globset', ['workspace-instructions']], ['ignore', ['filesystem']], @@ -957,7 +957,7 @@ export function runManifestParserSelfTest({ ], ['which', ['process-runtime']], ['win32job', ['process-runtime']], - ['windows', ['local-storage', 'process-runtime']], + ['windows', ['json-io', 'local-storage', 'process-runtime']], ['zip', ['lsp']], ]); for (const [dependencyName, ownerFeatures] of expectedServicesCoreOwners) { diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8485f94ad3..c2f71aabf8 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -2,6 +2,7 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; +use bitfun_core::service::config::{SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResult}; use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -224,6 +225,31 @@ pub async fn set_config( result } +#[tauri::command] +pub async fn save_cloud_speech_config( + state: State<'_, AppState>, + request: SaveCloudSpeechConfigRequest, +) -> Result { + match state.config_service.save_cloud_speech_config(request).await { + Ok(result) => { + state.ai_client_factory.invalidate_cache(); + crate::api::remote_connect_api::notify_settings_changed(); + info!( + "Cloud speech configuration saved atomically: model_id={}, created={}", + result.model_id, result.created + ); + Ok(result) + } + Err(error) => { + error!("Failed to save cloud speech configuration: {}", error); + Err(format!( + "Failed to save cloud speech configuration: {}", + error + )) + } + } +} + #[tauri::command] pub async fn reset_config( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2d772df151..119858ac6d 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1638,6 +1638,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("save_canvas_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "save_cloud_speech_config", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "save_git_repo_history", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 912032e0f7..af8fc576fa 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -113,6 +113,34 @@ static MAIN_WINDOW_CLOSE_PENDING_ON_MACOS: AtomicBool = AtomicBool::new(false); const MAIN_WINDOW_CLOSE_REQUESTED_EVENT: &str = "bitfun_main_window_close_requested"; const BROWSER_WEBVIEW_PAGE_LOAD_EVENT: &str = "browser-webview-page-load"; + +#[cfg(target_os = "windows")] +fn show_fatal_startup_error(message: &str) { + use windows::core::PCWSTR; + use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + + let title = "BitFun startup error" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let message = message + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + let _ = MessageBoxW( + None, + PCWSTR(message.as_ptr()), + PCWSTR(title.as_ptr()), + MB_OK | MB_ICONERROR, + ); + } +} + +#[cfg(not(target_os = "windows"))] +fn show_fatal_startup_error(message: &str) { + eprintln!("BitFun startup error: {message}"); +} const CRON_DESKTOP_START_FALLBACK_DELAY: Duration = Duration::from_secs(120); pub(crate) const MAIN_WINDOW_DEFAULT_WIDTH: f64 = 1200.0; pub(crate) const MAIN_WINDOW_DEFAULT_HEIGHT: f64 = 800.0; @@ -468,8 +496,21 @@ pub async fn run() { let step_started = Instant::now(); if let Err(e) = bitfun_core::service::config::initialize_global_config().await { log::error!("Failed to initialize global config service: {}", e); + show_fatal_startup_error(&format!( + "BitFun could not initialize its configuration and cannot continue.\n\n{e}\n\nSee early-startup.log for details." + )); return; } + if let Ok(config_service) = bitfun_core::service::config::get_global_config_service().await { + for diagnostic in config_service.load_diagnostics().await { + log::warn!( + "Startup configuration diagnostic: code={}, path={}, recoverability={:?}", + diagnostic.code, + diagnostic.path, + diagnostic.recoverability + ); + } + } startup_timings.record_elapsed("initialize_global_config", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_global_config", step_started); @@ -1291,6 +1332,7 @@ pub async fn run() { computer_use_request_permissions, computer_use_open_system_settings, set_config, + save_cloud_speech_config, reset_config, export_config, import_config, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 015311b2c7..f17b2ba7d8 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -87,7 +87,7 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../../services/services-core", default-features = false } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["json-io"] } # Integration service owner crate bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, optional = true } diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 779117b520..f2fb4c5375 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -2,10 +2,15 @@ //! //! A complete configuration management system based on the Provider mechanism. +use super::normalization::{ + isolate_invalid_ai_models, normalize_config_value, normalize_typed_config, + reconcile_model_references, reject_unsupported_schema, +}; use super::providers::ConfigProviderRegistry; use super::types::*; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; +use bitfun_services_core::json_store::JsonFileStore; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; @@ -14,9 +19,6 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::fs; -type ConfigMigrationFn = fn(Value) -> BitFunResult; -type ConfigMigration = (&'static str, &'static str, ConfigMigrationFn); - fn invalid_config_error(context: &str, result: &ConfigValidationResult) -> BitFunError { let messages = result .errors @@ -143,12 +145,6 @@ pub(crate) fn strip_removed_model_reasoning_fields(mut config: Value) -> Value { config } -fn normalize_legacy_config_value(config: Value) -> Value { - strip_removed_model_reasoning_fields(normalize_legacy_tool_permissions_config_value( - normalize_legacy_agent_model_defaults_config_value(config), - )) -} - fn config_value_for_persistence(config: &GlobalConfig) -> BitFunResult { let mut value = serde_json::to_value(config) .map_err(|e| BitFunError::config(format!("Failed to serialize config: {}", e)))?; @@ -204,6 +200,8 @@ pub struct ConfigManager { providers: ConfigProviderRegistry, config_file: PathBuf, path_manager: Arc, + backup_count: usize, + load_diagnostics: Vec, } /// Configuration manager settings. @@ -238,6 +236,7 @@ impl ConfigManager { let config_file = path_manager.app_config_file(); let providers = ConfigProviderRegistry::new(); + let backup_count = settings.backup_count; let mut manager = Self { config_dir, @@ -245,6 +244,8 @@ impl ConfigManager { providers, config_file, path_manager, + backup_count, + load_diagnostics: Vec::new(), }; manager.load_or_create_config().await?; @@ -290,12 +291,27 @@ impl ConfigManager { .await .map_err(|e| BitFunError::config(format!("Failed to read config file: {}", e)))?; - let mut config_value: Value = serde_json::from_str(&content).map_err(|e| { - BitFunError::config(format!("Failed to parse config file as JSON: {}", e)) - })?; - let normalized_config_value = normalize_legacy_config_value(config_value.clone()); - let legacy_config_normalized = normalized_config_value != config_value; - config_value = normalized_config_value; + let config_value: Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(error) => { + return self + .activate_default_recovery( + &content, + "invalid-json", + format!("Failed to parse config file as JSON: {error}"), + ) + .await; + } + }; + let normalized = normalize_config_value(config_value); + if let Err(error) = reject_unsupported_schema(&normalized.diagnostics) { + return self + .activate_default_recovery(&content, "unsupported-schema", error.to_string()) + .await; + } + let mut config_value = normalized.value; + let mut load_diagnostics = normalized.diagnostics; + let compatibility_normalized = normalized.changed; let file_version = config_value .get("version") @@ -305,16 +321,12 @@ impl ConfigManager { let current_version = env!("CARGO_PKG_VERSION").to_string(); - let needs_migration = !versions_match(&file_version, ¤t_version); - if needs_migration { + let app_version_changed = !versions_match(&file_version, ¤t_version); + if app_version_changed { info!( - "Config version change detected: {} -> {}", + "Config application version updated: {} -> {}", file_version, current_version ); - config_value = self - .migrate_config_version(&file_version, config_value) - .await?; - if let Some(obj) = config_value.as_object_mut() { obj.insert( "version".to_string(), @@ -325,9 +337,12 @@ impl ConfigManager { match serde_json::from_value::(config_value.clone()) { Ok(mut config) => { - Self::ensure_models_config(&mut config.ai.models); + load_diagnostics.extend(normalize_typed_config(&mut config)); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); + load_diagnostics.extend(isolate_invalid_ai_models(&mut config).await?); + load_diagnostics.extend(reconcile_model_references(&mut config).diagnostics); + self.config = config; let validation_result = self.validate_config().await?; @@ -338,14 +353,23 @@ impl ConfigManager { )); } - if needs_migration || legacy_config_normalized { + if compatibility_normalized || !load_diagnostics.is_empty() { + self.backup_raw_config(&content, "startup-normalization") + .await?; + } + if app_version_changed || compatibility_normalized || !load_diagnostics.is_empty() { self.config.version = current_version; self.save_config().await?; - info!("Config normalized and saved"); + info!( + "Config normalized and saved: diagnostics={}", + load_diagnostics.len() + ); } else { debug!("Loaded config from file"); } + self.load_diagnostics = load_diagnostics; + Ok(()) } Err(e) => { @@ -353,15 +377,42 @@ impl ConfigManager { "Config file deserialization failed, starting smart merge: {}", e ); - - self.smart_merge_config_from_value(config_value).await + self.backup_raw_config(&content, "pre-smart-merge").await?; + + match self.smart_merge_config_from_value(config_value).await { + Ok(()) => { + self.load_diagnostics.insert( + 0, + ConfigDiagnostic { + path: "$".to_string(), + message: format!( + "Repaired an incompatible configuration shape after typed deserialization failed: {e}" + ), + code: "CONFIG_SHAPE_REPAIRED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }, + ); + Ok(()) + } + Err(merge_error) => { + self.activate_default_recovery( + &content, + "invalid-shape", + format!( + "Config deserialization and smart merge failed: deserialize={e}; merge={merge_error}" + ), + ) + .await + } + } } } } /// Performs a smart merge from a JSON value. async fn smart_merge_config_from_value(&mut self, user_value: Value) -> BitFunResult<()> { - let user_value = normalize_legacy_config_value(user_value); + let user_value = normalize_config_value(user_value).value; let base_config = self.providers.get_default_config(); let base_value = serde_json::to_value(&base_config).map_err(|e| { @@ -373,8 +424,10 @@ impl ConfigManager { BitFunError::config(format!("Failed to deserialize merged config: {}", e)) })?; - Self::ensure_models_config(&mut config.ai.models); + let mut load_diagnostics = normalize_typed_config(&mut config); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); + load_diagnostics.extend(isolate_invalid_ai_models(&mut config).await?); + load_diagnostics.extend(reconcile_model_references(&mut config).diagnostics); self.config = config; @@ -388,21 +441,39 @@ impl ConfigManager { self.config.version = env!("CARGO_PKG_VERSION").to_string(); self.save_config().await?; + self.load_diagnostics = load_diagnostics; info!("Config automatically fixed and saved"); Ok(()) } - /// Auto-completes missing fields in model configuration (backward compatible). - /// Ensures older configurations won't panic. - fn ensure_models_config(models: &mut [AIModelConfig]) { - for model in models.iter_mut() { - model.ensure_category_and_capabilities(); - } - debug!( - "Auto-completed category and capabilities for {} models", - models.len() + async fn activate_default_recovery( + &mut self, + raw_content: &str, + reason: &str, + message: String, + ) -> BitFunResult<()> { + let backup_path = self.backup_raw_config(raw_content, reason).await?; + self.config = self.providers.get_default_config(); + Self::add_default_func_agent_models_config(&mut self.config.ai.func_agent_models); + self.config.version = env!("CARGO_PKG_VERSION").to_string(); + self.config.schema_version = CURRENT_CONFIG_SCHEMA_VERSION; + self.load_diagnostics = vec![ConfigDiagnostic { + path: "$".to_string(), + message: format!( + "{message}. Started with in-memory defaults; original configuration was preserved at {}", + backup_path.display() + ), + code: "CONFIG_DEFAULT_RECOVERY".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::DefaultsUsed, + }]; + warn!( + "Configuration recovery activated: reason={}, backup_path={}", + reason, + backup_path.display() ); + Ok(()) } /// Adds default configuration for functional agents (`func_agent_models`). @@ -422,27 +493,6 @@ impl ConfigManager { } } - /// Migrates configuration versions. - async fn migrate_config_version( - &self, - from_version: &str, - mut config: Value, - ) -> BitFunResult { - let migrations: Vec = vec![("0.0.0", "1.0.0", migrate_0_0_0_to_1_0_0)]; - - let mut current_version = from_version.to_string(); - - for (from, to, migrate_fn) in migrations { - if version_gte(¤t_version, from) && version_lt(¤t_version, to) { - debug!("Executing migration: {} -> {}", from, to); - config = migrate_fn(config)?; - current_version = to.to_string(); - } - } - - Ok(config) - } - /// Saves the configuration file. async fn save_config(&self) -> BitFunResult<()> { let content = serde_json::to_string_pretty(&config_value_for_persistence(&self.config)?) @@ -459,12 +509,75 @@ impl ConfigManager { } } - fs::write(&self.config_file, content).await.map_err(|e| { - BitFunError::config(format!( - "Failed to write config file {:?}: {}", - self.config_file, e - )) - })?; + JsonFileStore + .write_text_atomic_strict(&self.config_file, &content) + .await + .map_err(|e| { + BitFunError::config(format!( + "Failed to atomically write config file {:?}: {}", + self.config_file, e + )) + })?; + Ok(()) + } + + async fn backup_raw_config(&self, content: &str, reason: &str) -> BitFunResult { + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f"); + let backup_dir = self.config_dir.join("backups"); + fs::create_dir_all(&backup_dir) + .await + .map_err(|e| BitFunError::config(format!("Failed to create backup directory: {e}")))?; + let backup_file = backup_dir.join(format!("app_{reason}_{timestamp}.json")); + fs::write(&backup_file, content) + .await + .map_err(|e| BitFunError::config(format!("Failed to write config backup: {e}")))?; + self.prune_backups(&backup_dir).await?; + info!( + "Created pre-repair config backup: path={}", + backup_file.display() + ); + Ok(backup_file) + } + + async fn prune_backups(&self, backup_dir: &std::path::Path) -> BitFunResult<()> { + if self.backup_count == 0 { + return Ok(()); + } + let mut entries = fs::read_dir(backup_dir) + .await + .map_err(|e| BitFunError::config(format!("Failed to read backup directory: {e}")))?; + let mut files = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| BitFunError::config(format!("Failed to enumerate backups: {e}")))? + { + let is_repair_backup = entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("app_") && name.ends_with(".json")); + if !is_repair_backup { + continue; + } + let metadata = entry + .metadata() + .await + .map_err(|e| BitFunError::config(format!("Failed to inspect backup: {e}")))?; + if metadata.is_file() { + files.push((metadata.modified().ok(), entry.path())); + } + } + files.sort_by_key(|(modified, _)| *modified); + let remove_count = files.len().saturating_sub(self.backup_count); + for (_, path) in files.into_iter().take(remove_count) { + if let Err(error) = fs::remove_file(&path).await { + warn!( + "Failed to prune old config backup: path={}, error={}", + path.display(), + error + ); + } + } Ok(()) } @@ -494,6 +607,9 @@ impl ConfigManager { let path = canonical_config_path(path); self.set_value_by_path(path, json_value)?; + // Apply capability-driven canonicalization before validation and persistence. + // Speech/embedding/image-only models must never carry text-generation sentinels. + normalize_typed_config(&mut self.config); self.config.last_modified = chrono::Utc::now(); let validation_result = match self.validate_config().await { @@ -511,7 +627,14 @@ impl ConfigManager { )); } - self.notify_config_changed(path, &old_config).await?; + if path.is_empty() { + for provider_name in self.providers.get_provider_names() { + self.notify_config_changed(&provider_name, &old_config) + .await?; + } + } else { + self.notify_config_changed(path, &old_config).await?; + } self.save_config().await?; @@ -568,6 +691,10 @@ impl ConfigManager { &self.config } + pub fn load_diagnostics(&self) -> &[ConfigDiagnostic] { + &self.load_diagnostics + } + /// Validates configuration. pub async fn validate_config(&self) -> BitFunResult { self.providers.validate_config(&self.config).await @@ -582,11 +709,18 @@ impl ConfigManager { /// Imports configuration. pub async fn import_config(&mut self, config_data: serde_json::Value) -> BitFunResult<()> { let old_config = self.config.clone(); - let config_data = normalize_legacy_config_value(config_data); + let normalized = normalize_config_value(config_data); + reject_unsupported_schema(&normalized.diagnostics)?; + let config_data = normalized.value; - let imported_config: GlobalConfig = serde_json::from_value(config_data) + let mut imported_config: GlobalConfig = serde_json::from_value(config_data) .map_err(|e| BitFunError::config(format!("Failed to parse imported config: {}", e)))?; + let mut import_diagnostics = normalized.diagnostics; + import_diagnostics.extend(normalize_typed_config(&mut imported_config)); + import_diagnostics.extend(isolate_invalid_ai_models(&mut imported_config).await?); + import_diagnostics.extend(reconcile_model_references(&mut imported_config).diagnostics); + let validation_result = self.providers.validate_config(&imported_config).await?; if !validation_result.valid { return Err(invalid_config_error( @@ -596,6 +730,7 @@ impl ConfigManager { } self.config = imported_config; + self.load_diagnostics = import_diagnostics; self.config.last_modified = chrono::Utc::now(); for provider_name in self.providers.get_provider_names() { @@ -852,59 +987,6 @@ pub(crate) fn versions_match(v1: &str, v2: &str) -> bool { v1 == v2 } -/// Returns whether `v1 >= v2`. -pub(crate) fn version_gte(v1: &str, v2: &str) -> bool { - parse_version(v1) >= parse_version(v2) -} - -/// Returns whether `v1 < v2`. -pub(crate) fn version_lt(v1: &str, v2: &str) -> bool { - parse_version(v1) < parse_version(v2) -} - -/// Parses a version string into a tuple `(major, minor, patch)`. -pub(crate) fn parse_version(version: &str) -> (u32, u32, u32) { - let parts: Vec<&str> = version.split('.').collect(); - let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); - let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - (major, minor, patch) -} - -/// Migration function: `0.0.0 -> 1.0.0`. -/// -/// This migration is an example showing how to handle configuration upgrades. -pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { - debug!("Executing config migration: 0.0.0 -> 1.0.0"); - - if let Some(app) = config.get_mut("app").and_then(|v| v.as_object_mut()) { - if !app.contains_key("ai_experience") { - app.insert( - "ai_experience".to_string(), - serde_json::json!({ - "enable_session_title_generation": true, - "enable_welcome_panel_ai_analysis": false - }), - ); - } - } - - if let Some(ai) = config.get_mut("ai").and_then(|v| v.as_object_mut()) { - if !ai.contains_key("super_agent_models") { - ai.insert( - "super_agent_models".to_string(), - Value::Object(serde_json::Map::new()), - ); - } - if !ai.contains_key("sub_agent_models") { - ai.insert("sub_agent_models".to_string(), serde_json::json!({})); - } - } - - debug!("Migration 0.0.0 -> 1.0.0 completed"); - Ok(config) -} - #[cfg(test)] mod tests { use super::{ diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 94d2f02940..4fb9ccaf25 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -10,6 +10,7 @@ pub mod global; pub mod manager; #[cfg(feature = "agent-runtime")] pub mod mode_config_canonicalizer; +pub mod normalization; pub mod project_permission_store; pub mod providers; pub mod service; @@ -29,6 +30,11 @@ pub use mode_config_canonicalizer::{ canonicalize_agent_profile_configs, AgentProfileConfigCanonicalizationReport, AgentProfileConfigUpdateInfo, }; +pub use normalization::{ + isolate_invalid_ai_models, normalize_config_value, normalize_typed_config, + reconcile_model_references, reject_unsupported_schema, ConfigNormalizationResult, + ModelReferenceReconcileResult, +}; pub use providers::ConfigProviderRegistry; pub use service::{ConfigExport, ConfigHealthStatus, ConfigImportResult, ConfigService}; pub use types::*; diff --git a/src/crates/assembly/core/src/service/config/normalization.rs b/src/crates/assembly/core/src/service/config/normalization.rs new file mode 100644 index 0000000000..3d776c04bc --- /dev/null +++ b/src/crates/assembly/core/src/service/config/normalization.rs @@ -0,0 +1,514 @@ +use super::manager::{ + normalize_legacy_agent_model_defaults_config_value, + normalize_legacy_tool_permissions_config_value, strip_removed_model_reasoning_fields, +}; +use super::providers::AIConfigProvider; +use super::types::{ + ConfigDiagnostic, ConfigDiagnosticRecoverability, ConfigDiagnosticSeverity, ConfigProvider, + GlobalConfig, ModelCapability, SubagentModelSelection, CURRENT_CONFIG_SCHEMA_VERSION, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct ConfigNormalizationResult { + pub value: Value, + pub diagnostics: Vec, + pub changed: bool, +} + +/// Applies deterministic, credential-preserving compatibility normalization +/// before typed deserialization and strict semantic validation. +pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult { + let original = config.clone(); + let mut diagnostics = Vec::new(); + let mut value = + strip_removed_model_reasoning_fields(normalize_legacy_tool_permissions_config_value( + normalize_legacy_agent_model_defaults_config_value(config), + )); + + let previous_schema = value + .get("schema_version") + .and_then(Value::as_u64) + .unwrap_or(0); + if previous_schema > u64::from(CURRENT_CONFIG_SCHEMA_VERSION) { + diagnostics.push(ConfigDiagnostic { + path: "schema_version".to_string(), + message: format!( + "Configuration schema {previous_schema} is newer than supported schema {CURRENT_CONFIG_SCHEMA_VERSION}" + ), + code: "CONFIG_SCHEMA_TOO_NEW".to_string(), + severity: ConfigDiagnosticSeverity::Error, + recoverability: ConfigDiagnosticRecoverability::None, + }); + return ConfigNormalizationResult { + changed: value != original, + value, + diagnostics, + }; + } + if previous_schema < u64::from(CURRENT_CONFIG_SCHEMA_VERSION) { + if let Some(root) = value.as_object_mut() { + root.insert( + "schema_version".to_string(), + Value::from(CURRENT_CONFIG_SCHEMA_VERSION), + ); + } + diagnostics.push(ConfigDiagnostic { + path: "schema_version".to_string(), + message: format!( + "Configuration schema upgraded from {previous_schema} to {CURRENT_CONFIG_SCHEMA_VERSION}" + ), + code: "CONFIG_SCHEMA_UPGRADED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); + } + + ConfigNormalizationResult { + changed: value != original, + value, + diagnostics, + } +} + +pub fn reject_unsupported_schema(diagnostics: &[ConfigDiagnostic]) -> BitFunResult<()> { + if let Some(diagnostic) = diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "CONFIG_SCHEMA_TOO_NEW") + { + return Err(BitFunError::validation(diagnostic.message.clone())); + } + Ok(()) +} + +/// Canonicalizes typed model fields whose meaning is capability-dependent. +pub fn normalize_typed_config(config: &mut GlobalConfig) -> Vec { + let mut diagnostics = Vec::new(); + config.schema_version = CURRENT_CONFIG_SCHEMA_VERSION; + + for (index, model) in config.ai.models.iter_mut().enumerate() { + model.ensure_category_and_capabilities(); + let model_id = model.id.clone(); + for field in model.normalize_inapplicable_generation_fields() { + diagnostics.push(ConfigDiagnostic { + path: format!("ai.models[{index}].{field}"), + message: format!( + "Cleared text-generation-only field from model '{}' because it does not support text_chat", + model_id + ), + code: "MODEL_FIELD_NOT_APPLICABLE".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); + } + } + + diagnostics +} + +/// Disables only individually invalid model entries so a local model mistake +/// cannot prevent the rest of the product from starting. Cross-model/default +/// integrity is repaired separately by the model reconciliation pass. +pub async fn isolate_invalid_ai_models( + config: &mut GlobalConfig, +) -> BitFunResult> { + let mut diagnostics = Vec::new(); + + for index in 0..config.ai.models.len() { + if !config.ai.models[index].enabled { + continue; + } + + let mut isolated_ai = super::types::AIConfig::default(); + isolated_ai.models = vec![config.ai.models[index].clone()]; + + let validation = AIConfigProvider + .validate_config(&serde_json::to_value(isolated_ai)?) + .await; + if let Err(error) = validation { + let error_message = error.to_string(); + // Reasoning schemas are cross-cutting runtime contracts. Keep these + // as hard failures so a malformed preset is not silently hidden. + if error_message.to_ascii_lowercase().contains("reasoning") { + return Err(error); + } + let model_id = config.ai.models[index].id.clone(); + config.ai.models[index].enabled = false; + diagnostics.push(ConfigDiagnostic { + path: format!("ai.models[{index}]"), + message: format!( + "Disabled invalid model '{}' during configuration recovery", + model_id + ), + code: "INVALID_MODEL_DISABLED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::ModelDisabled, + }); + log::warn!( + "Disabled invalid model during configuration recovery: model_id={}, error={}", + model_id, + error_message + ); + } + } + + Ok(diagnostics) +} + +#[derive(Debug, Clone, Default)] +pub struct ModelReferenceReconcileResult { + pub invalidated_model_ids: Vec, + pub default_models_changed: bool, + pub func_agent_models_changed: bool, + pub agent_model_defaults_changed: bool, + pub diagnostics: Vec, +} + +impl ModelReferenceReconcileResult { + pub fn is_noop(&self) -> bool { + !self.default_models_changed + && !self.func_agent_models_changed + && !self.agent_model_defaults_changed + } +} + +fn enabled_model_with_capability( + config: &GlobalConfig, + model_id: &str, + capability: ModelCapability, +) -> bool { + config.ai.models.iter().any(|model| { + model.enabled && model.id == model_id && model.supports_capability(capability.clone()) + }) +} + +fn first_enabled_model_with_capability( + config: &GlobalConfig, + capability: ModelCapability, +) -> Option { + config + .ai + .models + .iter() + .find(|model| model.enabled && model.supports_capability(capability.clone())) + .map(|model| model.id.clone()) +} + +fn diagnose_reference_repair( + diagnostics: &mut Vec, + path: &str, + previous: Option<&str>, + replacement: Option<&str>, +) { + diagnostics.push(ConfigDiagnostic { + path: path.to_string(), + message: format!( + "Repaired model reference from {:?} to {:?} to match the slot capability", + previous, replacement + ), + code: "MODEL_REFERENCE_REPAIRED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); +} + +/// Reconciles every product model reference against both enablement and the +/// capability required by its consumer. +pub fn reconcile_model_references(config: &mut GlobalConfig) -> ModelReferenceReconcileResult { + let snapshot = config.clone(); + let mut result = ModelReferenceReconcileResult::default(); + let mut invalidated = HashSet::new(); + + let direct_text_reference_is_valid = |reference: &str| { + matches!(reference, "auto" | "primary" | "fast") + || enabled_model_with_capability(&snapshot, reference, ModelCapability::TextChat) + }; + + config.ai.func_agent_models.retain(|agent, model_ref| { + let valid = direct_text_reference_is_valid(model_ref); + if !valid { + invalidated.insert(model_ref.clone()); + result.func_agent_models_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + &format!("ai.func_agent_models.{agent}"), + Some(model_ref), + None, + ); + } + valid + }); + + if !direct_text_reference_is_valid(&config.ai.agent_model_defaults.mode) { + invalidated.insert(config.ai.agent_model_defaults.mode.clone()); + let previous = + std::mem::replace(&mut config.ai.agent_model_defaults.mode, "auto".to_string()); + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.mode", + Some(&previous), + Some("auto"), + ); + } + + if config + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)) + { + let previous = config + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id() + .map(str::to_string); + if let Some(previous) = previous.as_ref() { + invalidated.insert(previous.clone()); + } + config.ai.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("fast"); + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.subagents.default", + previous.as_deref(), + Some("fast"), + ); + } + + config + .ai + .agent_model_defaults + .subagents + .builtin + .retain(|subagent_id, selection| { + let invalid = selection + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)); + if invalid { + if let Some(model_id) = selection.fixed_model_id() { + invalidated.insert(model_id.to_string()); + diagnose_reference_repair( + &mut result.diagnostics, + &format!("ai.agent_model_defaults.subagents.builtin.{subagent_id}"), + Some(model_id), + None, + ); + } + result.agent_model_defaults_changed = true; + } + !invalid + }); + + if config + .ai + .agent_model_defaults + .subagents + .fork + .fixed_model_id() + .is_some_and(|model_id| !direct_text_reference_is_valid(model_id)) + { + let previous = config + .ai + .agent_model_defaults + .subagents + .fork + .fixed_model_id() + .map(str::to_string); + if let Some(previous) = previous.as_ref() { + invalidated.insert(previous.clone()); + } + config.ai.agent_model_defaults.subagents.fork = SubagentModelSelection::Inherit; + result.agent_model_defaults_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + "ai.agent_model_defaults.subagents.fork", + previous.as_deref(), + Some("inherit"), + ); + } + + let mut reconcile_slot = |slot: &mut Option, + path: &str, + capability: ModelCapability, + fill_when_missing: bool| { + let previous = slot.clone(); + let valid = previous + .as_deref() + .is_some_and(|id| enabled_model_with_capability(&snapshot, id, capability.clone())); + if valid || (previous.is_none() && !fill_when_missing) { + return; + } + let replacement = first_enabled_model_with_capability(&snapshot, capability); + if replacement == previous { + return; + } + if let Some(previous) = previous.as_ref().filter(|id| !id.is_empty()) { + invalidated.insert(previous.clone()); + } + *slot = replacement; + result.default_models_changed = true; + diagnose_reference_repair( + &mut result.diagnostics, + path, + previous.as_deref(), + slot.as_deref(), + ); + }; + + reconcile_slot( + &mut config.ai.default_models.primary, + "ai.default_models.primary", + ModelCapability::TextChat, + true, + ); + reconcile_slot( + &mut config.ai.default_models.fast, + "ai.default_models.fast", + ModelCapability::TextChat, + true, + ); + reconcile_slot( + &mut config.ai.default_models.image_understanding, + "ai.default_models.image_understanding", + ModelCapability::ImageUnderstanding, + false, + ); + reconcile_slot( + &mut config.ai.default_models.image_generation, + "ai.default_models.image_generation", + ModelCapability::ImageGeneration, + false, + ); + reconcile_slot( + &mut config.ai.default_models.search, + "ai.default_models.search", + ModelCapability::Search, + false, + ); + reconcile_slot( + &mut config.ai.default_models.speech_recognition, + "ai.default_models.speech_recognition", + ModelCapability::SpeechRecognition, + false, + ); + + result.invalidated_model_ids = invalidated.into_iter().collect(); + result.invalidated_model_ids.sort(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::config::types::{AIModelConfig, ModelCapability, ModelCategory}; + + #[test] + fn pure_speech_models_drop_text_generation_sentinels() { + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "speech-cloud".to_string(), + name: "Qwen ASR".to_string(), + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + enabled: true, + ..AIModelConfig::default() + }); + + let diagnostics = normalize_typed_config(&mut config); + + assert_eq!(config.ai.models[0].context_window, None); + assert_eq!(config.ai.models[0].max_tokens, None); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == "MODEL_FIELD_NOT_APPLICABLE")); + } + + #[test] + fn mixed_text_and_speech_models_keep_generation_fields() { + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "mixed".to_string(), + category: ModelCategory::GeneralChat, + capabilities: vec![ + ModelCapability::TextChat, + ModelCapability::SpeechRecognition, + ], + context_window: Some(64_000), + max_tokens: Some(8_000), + ..AIModelConfig::default() + }); + + assert!(normalize_typed_config(&mut config).is_empty()); + assert_eq!(config.ai.models[0].context_window, Some(64_000)); + assert_eq!(config.ai.models[0].max_tokens, Some(8_000)); + } + + #[test] + fn default_slots_reconcile_by_capability() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![ + AIModelConfig { + id: "speech".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + ..AIModelConfig::default() + }, + AIModelConfig { + id: "text".to_string(), + enabled: true, + category: ModelCategory::GeneralChat, + capabilities: vec![ModelCapability::TextChat], + ..AIModelConfig::default() + }, + ]; + config.ai.default_models.primary = Some("speech".to_string()); + config.ai.default_models.fast = Some("speech".to_string()); + config.ai.default_models.speech_recognition = Some("text".to_string()); + + let result = reconcile_model_references(&mut config); + + assert_eq!(config.ai.default_models.primary.as_deref(), Some("text")); + assert_eq!(config.ai.default_models.fast.as_deref(), Some("text")); + assert_eq!( + config.ai.default_models.speech_recognition.as_deref(), + Some("speech") + ); + assert!(result.default_models_changed); + } + + #[tokio::test] + async fn global_ai_errors_do_not_disable_individually_valid_models() { + let mut config = GlobalConfig::default(); + config.ai.stream_idle_timeout_secs = Some(0); + config.ai.models.push(AIModelConfig { + id: "valid-text".to_string(), + name: "Valid text model".to_string(), + provider: "openai".to_string(), + model_name: "text-model".to_string(), + base_url: "https://example.com/v1".to_string(), + enabled: true, + capabilities: vec![ModelCapability::TextChat], + context_window: Some(64_000), + ..AIModelConfig::default() + }); + + let diagnostics = isolate_invalid_ai_models(&mut config) + .await + .expect("model isolation should succeed"); + + assert!(diagnostics.is_empty()); + assert!(config.ai.models[0].enabled); + } +} diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 416186d2b5..81391b2b6b 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -34,6 +34,83 @@ fn serialize_default_config(section: &str, value: impl serde::Serialize) -> serd /// AI configuration provider. pub struct AIConfigProvider; +fn ai_validation_error_location(message: &str) -> (String, String) { + let model_field = if message.contains("Model name is required") { + Some(("name", "MODEL_NAME_INVALID")) + } else if message.contains("Model provider is required") { + Some(("provider", "MODEL_PROVIDER_INVALID")) + } else if message.contains("context_window") { + Some(("context_window", "MODEL_CONTEXT_WINDOW_INVALID")) + } else if message.contains("max_tokens") { + Some(("max_tokens", "MODEL_MAX_TOKENS_INVALID")) + } else if message.contains("reasoning config") { + Some(("reasoning", "MODEL_REASONING_INVALID")) + } else if message.contains("reasoning default preset") { + Some(( + "reasoning.default_preset", + "MODEL_REASONING_DEFAULT_INVALID", + )) + } else if message.contains("reasoning target") { + Some(("reasoning", "MODEL_REASONING_TARGET_INVALID")) + } else if message.contains("reasoning preset") { + Some(("reasoning.presets", "MODEL_REASONING_PRESET_INVALID")) + } else { + None + }; + + if let Some((field, code)) = model_field { + if let Some(index) = message + .rsplit_once(" at index ") + .and_then(|(_, suffix)| suffix.split(':').next()) + .and_then(|value| value.parse::().ok()) + { + return (format!("ai.models[{index}].{field}"), code.to_string()); + } + } + + if message.contains("stream_idle_timeout_secs") { + return ( + "ai.stream_idle_timeout_secs".to_string(), + "AI_STREAM_IDLE_TIMEOUT_INVALID".to_string(), + ); + } + if message.contains("stream_ttft_timeout_secs") { + return ( + "ai.stream_ttft_timeout_secs".to_string(), + "AI_STREAM_TTFT_TIMEOUT_INVALID".to_string(), + ); + } + if message.starts_with("Function Agent '") { + if let Some((_, suffix)) = message.split_once("Function Agent '") { + if let Some((agent, _)) = suffix.split_once('\'') { + return ( + format!("ai.func_agent_models.{agent}"), + "FUNC_AGENT_MODEL_INVALID".to_string(), + ); + } + } + } + + ("ai".to_string(), "VALIDATION_ERROR".to_string()) +} + +fn ai_validation_warning_location(config: &GlobalConfig, message: &str) -> String { + let Some(model_name) = message + .strip_prefix("Model '") + .and_then(|value| value.split_once("' has empty API key")) + .map(|(name, _)| name) + else { + return "ai".to_string(); + }; + config + .ai + .models + .iter() + .position(|model| model.name == model_name) + .map(|index| format!("ai.models[{index}].api_key")) + .unwrap_or_else(|| "ai".to_string()) +} + #[async_trait] impl ConfigProvider for AIConfigProvider { fn name(&self) -> &str { @@ -76,6 +153,9 @@ impl ConfigProvider for AIConfigProvider { } for (index, model) in ai_config.models.iter().enumerate() { + if !model.enabled { + continue; + } if model.name.trim().is_empty() { return Err(BitFunError::validation(format!( "Model name is required at index {}", @@ -91,28 +171,30 @@ impl ConfigProvider for AIConfigProvider { if model.api_key.trim().is_empty() { warnings.push(format!("Model '{}' has empty API key", model.name)); } - if let Some(context_window) = model.context_window { - if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { - return Err(BitFunError::validation(format!( - "Model '{}' context_window must be at least {}", - model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS - ))); + if model.supports_text_generation() { + if let Some(context_window) = model.context_window { + if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS { + return Err(BitFunError::validation(format!( + "Model '{}' context_window must be at least {} at index {}", + model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS, index + ))); + } } - } - if let Some(max_tokens) = model.max_tokens { - if max_tokens == 0 { - return Err(BitFunError::validation(format!( - "Model '{}' max_tokens must be greater than 0", - model.name - ))); + if let Some(max_tokens) = model.max_tokens { + if max_tokens == 0 { + return Err(BitFunError::validation(format!( + "Model '{}' max_tokens must be greater than 0 at index {}", + model.name, index + ))); + } } - } - if let Some(temperature) = model.temperature { - if !temperature.is_nan() && !(0.0..=2.0).contains(&temperature) { - warnings.push(format!( - "Model '{}' temperature should be between 0 and 2", - model.name - )); + if let Some(temperature) = model.temperature { + if !temperature.is_nan() && !(0.0..=2.0).contains(&temperature) { + warnings.push(format!( + "Model '{}' temperature should be between 0 and 2", + model.name + )); + } } } @@ -198,7 +280,10 @@ impl ConfigProvider for AIConfigProvider { } for (func_agent_name, model_id) in &ai_config.func_agent_models { - if !ai_config.models.iter().any(|m| m.id == *model_id) + if !ai_config + .models + .iter() + .any(|m| m.enabled && m.id == *model_id) && model_id != "primary" && model_id != "fast" { @@ -625,24 +710,53 @@ impl ConfigProviderRegistry { Ok(provider_warnings) => { warnings.extend(provider_warnings.into_iter().map(|msg| { ConfigValidationWarning { - path: provider_name.to_string(), + path: if provider_name == "ai" { + ai_validation_warning_location(config, &msg) + } else { + provider_name.to_string() + }, message: msg, code: "VALIDATION_WARNING".to_string(), severity: "warning".to_string(), } })) } - Err(e) => errors.push(ConfigValidationError { - path: provider_name.to_string(), - message: e.to_string(), - code: "VALIDATION_ERROR".to_string(), - severity: "error".to_string(), - }), + Err(e) => { + let message = e.to_string(); + let (path, code) = if provider_name == "ai" { + ai_validation_error_location(&message) + } else { + (provider_name.to_string(), "VALIDATION_ERROR".to_string()) + }; + errors.push(ConfigValidationError { + path, + message, + code, + severity: "error".to_string(), + }); + } } } Ok(ConfigValidationResult { valid: errors.is_empty(), + diagnostics: errors + .iter() + .map(|error| ConfigDiagnostic { + path: error.path.clone(), + message: error.message.clone(), + code: error.code.clone(), + severity: ConfigDiagnosticSeverity::Error, + recoverability: ConfigDiagnosticRecoverability::None, + }) + .chain(warnings.iter().map(|warning| ConfigDiagnostic { + path: warning.path.clone(), + message: warning.message.clone(), + code: warning.code.clone(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::None, + })) + .collect(), errors, warnings, }) @@ -726,6 +840,7 @@ mod tests { name: "Test model".to_string(), provider: "openai".to_string(), context_window: Some(MIN_MODEL_CONTEXT_WINDOW_TOKENS - 1), + enabled: true, ..AIModelConfig::default() }); let value = serde_json::to_value(config).expect("AI config should serialize"); @@ -740,6 +855,75 @@ mod tests { .contains("context_window must be at least 32000")); } + #[tokio::test] + async fn accepts_generation_sentinels_on_pure_speech_models() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Qwen ASR".to_string(), + provider: "openai".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + ..AIModelConfig::default() + }); + + AIConfigProvider + .validate_config(&serde_json::to_value(config).unwrap()) + .await + .expect("pure speech models do not use generation token fields"); + } + + #[tokio::test] + async fn mixed_text_and_speech_models_still_require_a_valid_context_window() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + name: "Mixed model".to_string(), + provider: "openai".to_string(), + enabled: true, + capabilities: vec![ + ModelCapability::TextChat, + ModelCapability::SpeechRecognition, + ], + context_window: Some(0), + ..AIModelConfig::default() + }); + + let error = AIConfigProvider + .validate_config(&serde_json::to_value(config).unwrap()) + .await + .expect_err("text-capable models must retain generation validation"); + assert!(error + .to_string() + .contains("context_window must be at least")); + } + + #[tokio::test] + async fn registry_reports_precise_model_validation_paths_and_codes() { + let mut config = AIConfig::default(); + config.models.push(AIModelConfig { + id: "broken".to_string(), + name: "Broken model".to_string(), + provider: "openai".to_string(), + enabled: true, + context_window: Some(0), + ..AIModelConfig::default() + }); + + let result = ConfigProviderRegistry::new() + .validate_config(&GlobalConfig { + ai: config, + ..GlobalConfig::default() + }) + .await + .expect("validation result"); + + assert_eq!(result.errors[0].path, "ai.models[0].context_window"); + assert_eq!(result.errors[0].code, "MODEL_CONTEXT_WINDOW_INVALID"); + assert_eq!(result.diagnostics[0].path, "ai.models[0].context_window"); + } + #[tokio::test] async fn rejects_invalid_canonical_reasoning_actions() { for (action, expected) in [ @@ -851,7 +1035,7 @@ mod tests { .expect("registry validation result"); assert!(!validation.valid); - assert_eq!(validation.errors[0].path, "ai"); + assert_eq!(validation.errors[0].path, "ai.models[0].reasoning"); assert!(validation.errors[0] .message .contains("budget_tokens value must be greater than 0")); diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 3068a02506..46053b0e77 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -6,8 +6,6 @@ use super::manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; use super::types::*; use crate::util::errors::*; use log::{info, warn}; -use std::collections::HashSet; - use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; @@ -63,8 +61,15 @@ impl ConfigService { manager: Arc::new(RwLock::new(manager)), }; - if let Err(e) = service.reconcile_models("startup").await { - warn!("Model reconcile at startup failed: {}", e); + let recovered_with_defaults = service + .load_diagnostics() + .await + .iter() + .any(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY"); + if !recovered_with_defaults { + if let Err(e) = service.reconcile_models("startup").await { + warn!("Model reconcile at startup failed: {}", e); + } } Ok(service) @@ -182,7 +187,15 @@ impl ConfigService { /// Validates configuration. pub async fn validate_config(&self) -> BitFunResult { let manager = self.manager.read().await; - manager.validate_config().await + let mut result = manager.validate_config().await?; + result + .diagnostics + .extend(manager.load_diagnostics().iter().cloned()); + Ok(result) + } + + pub async fn load_diagnostics(&self) -> Vec { + self.manager.read().await.load_diagnostics().to_vec() } /// Exports configuration. @@ -377,6 +390,114 @@ impl ConfigService { self.set_config("ai.models", &config.ai.models).await } + /// Atomically upserts a pure speech-recognition model, selects it as the + /// speech default, and switches voice input to the cloud provider. + pub async fn save_cloud_speech_config( + &self, + request: SaveCloudSpeechConfigRequest, + ) -> BitFunResult { + let name = request.name.trim(); + let base_url = request.base_url.trim().trim_end_matches('/'); + let model_name = request.model_name.trim(); + let api_key = request.api_key.trim(); + if name.is_empty() || base_url.is_empty() || model_name.is_empty() || api_key.is_empty() { + return Err(BitFunError::validation( + "Cloud speech name, base URL, model name, and API key are required".to_string(), + )); + } + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return Err(BitFunError::validation( + "Cloud speech base URL must use http or https".to_string(), + )); + } + + let request_url = request + .request_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + if base_url.ends_with("/audio/transcriptions") { + base_url.to_string() + } else { + format!("{base_url}/audio/transcriptions") + } + }); + if !request_url.starts_with("http://") && !request_url.starts_with("https://") { + return Err(BitFunError::validation( + "Cloud speech request URL must use http or https".to_string(), + )); + } + + let mut manager = self.manager.write().await; + let mut config = manager.get_config().clone(); + let model_id = request + .config_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("speech_cloud_{}", uuid::Uuid::new_v4().simple())); + let existing_index = config + .ai + .models + .iter() + .position(|model| model.id == model_id); + let created = existing_index.is_none(); + let existing_metadata = existing_index + .and_then(|index| config.ai.models[index].metadata.clone()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + let mut metadata = existing_metadata; + metadata.insert( + "speech_provider_preset".to_string(), + serde_json::Value::String(request.preset.trim().to_string()), + ); + let model = AIModelConfig { + id: model_id.clone(), + name: name.to_string(), + provider: "openai".to_string(), + model_name: model_name.to_string(), + base_url: base_url.to_string(), + request_url: Some(request_url), + api_key: api_key.to_string(), + context_window: None, + max_tokens: None, + temperature: None, + top_p: None, + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + recommended_for: vec!["voice_input".to_string()], + metadata: Some(serde_json::Value::Object(metadata)), + auth: AuthConfig::ApiKey, + ..AIModelConfig::default() + }; + match existing_index { + Some(index) => config.ai.models[index] = model, + None => config.ai.models.push(model), + } + config.ai.default_models.speech_recognition = Some(model_id.clone()); + config.app.ai_experience.voice_input.provider = "cloud".to_string(); + config.app.ai_experience.voice_input.model_id = model_id.clone(); + + // The caller may be updating an existing model id. Reconcile all + // capability-specific slots before the single persistence operation so + // replacing a text model with a speech-only model cannot leave primary, + // fast, or agent references pointing at a non-text runtime target. + super::normalization::reconcile_model_references(&mut config); + manager.set("", &config).await?; + drop(manager); + + super::global::GlobalConfigManager::broadcast_update( + super::global::ConfigUpdateEvent::ModelConfigurationUpdated, + ) + .await; + + Ok(SaveCloudSpeechConfigResult { model_id, created }) + } + /// Bring `ai.default_models`, `ai.agent_model_defaults`, and /// `ai.func_agent_models` back into a consistent state with `ai.models`. /// @@ -396,229 +517,17 @@ impl ConfigService { /// `caller` is logged for diagnostics (e.g. `set_config`, `update_ai_model`). pub async fn reconcile_models(&self, caller: &str) -> BitFunResult { let mut config: GlobalConfig = self.get_config(None).await?; + let reconciliation = super::normalization::reconcile_model_references(&mut config); - let enabled_ids: HashSet = config - .ai - .models - .iter() - .filter(|m| m.enabled) - .map(|m| m.id.clone()) - .collect(); - let is_active = |reference: &str| -> bool { - // Special selectors are always considered active; their actual - // resolution happens at runtime against the (already reconciled) - // default slots. - matches!(reference, "auto" | "primary" | "fast") || enabled_ids.contains(reference) - }; - - let classify_invalid = |reference: &str, invalidated: &mut HashSet| -> bool { - if is_active(reference) { - return false; - } - invalidated.insert(reference.to_string()); - true - }; - - let mut invalidated: HashSet = HashSet::new(); - let mut func_agent_models_changed = false; - let mut agent_model_defaults_changed = false; - let mut default_models_changed = false; - - // 1. func_agent_models - let func_keys_to_remove: Vec = config - .ai - .func_agent_models - .iter() - .filter_map(|(agent, model_ref)| { - if classify_invalid(model_ref, &mut invalidated) { - Some(agent.clone()) - } else { - None - } - }) - .collect(); - for agent in func_keys_to_remove { - warn!( - "Reconcile ({caller}): clearing ai.func_agent_models[{agent}] because target model is missing or disabled" - ); - config.ai.func_agent_models.remove(&agent); - func_agent_models_changed = true; - } - - // 2. future mode and delegated-subagent defaults - if classify_invalid( - config.ai.agent_model_defaults.mode.as_str(), - &mut invalidated, - ) { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.mode because target model is missing or disabled" - ); - config.ai.agent_model_defaults.mode = "auto".to_string(); - agent_model_defaults_changed = true; - } - - if config - .ai - .agent_model_defaults - .subagents - .default_selection - .fixed_model_id() - .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) - { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.default because target model is missing or disabled" - ); - config.ai.agent_model_defaults.subagents.default_selection = - SubagentModelSelection::fixed("fast"); - agent_model_defaults_changed = true; - } - - let builtin_keys_to_remove: Vec = config - .ai - .agent_model_defaults - .subagents - .builtin - .iter() - .filter_map(|(subagent_id, selection)| { - selection - .fixed_model_id() - .filter(|model_id| classify_invalid(model_id, &mut invalidated)) - .map(|_| subagent_id.clone()) - }) - .collect(); - for subagent_id in builtin_keys_to_remove { - warn!( - "Reconcile ({caller}): clearing ai.agent_model_defaults.subagents.builtin[{subagent_id}] because target model is missing or disabled" - ); - config - .ai - .agent_model_defaults - .subagents - .builtin - .remove(&subagent_id); - agent_model_defaults_changed = true; - } - - if config - .ai - .agent_model_defaults - .subagents - .fork - .fixed_model_id() - .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) - { - warn!( - "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.fork because target model is missing or disabled" - ); - config.ai.agent_model_defaults.subagents.fork = SubagentModelSelection::Inherit; - agent_model_defaults_changed = true; - } - - // 3. default model slots - let fallback_id = config.ai.first_enabled_model_id(); - let image_understanding_fallback_id = config - .ai - .models - .iter() - .find(|model| model.enabled && model.supports_image_understanding()) - .map(|model| model.id.clone()); - let mut repoint_default_slot = |slot: &mut Option, slot_name: &str| { - let needs_fix = match slot.as_deref() { - Some("") => true, - Some(value) => !is_active(value), - None => false, - }; - if !needs_fix { - return; - } - - if let Some(current) = slot.as_deref() { - classify_invalid(current, &mut invalidated); - } - - match fallback_id.as_ref() { - Some(new_id) => { - info!( - "Reconcile ({caller}): default_models.{slot_name} repointed: {:?} -> {}", - slot, new_id - ); - *slot = Some(new_id.clone()); - } - None => { - info!( - "Reconcile ({caller}): default_models.{slot_name} cleared (no enabled model available); previous={:?}", - slot - ); - *slot = None; - } - } - default_models_changed = true; - }; - - repoint_default_slot(&mut config.ai.default_models.primary, "primary"); - repoint_default_slot(&mut config.ai.default_models.fast, "fast"); - - let image_understanding_needs_fix = - match config.ai.default_models.image_understanding.as_deref() { - Some("") => true, - Some(value) => !config.ai.models.iter().any(|model| { - model.enabled && model.supports_image_understanding() && model.id == value - }), - None => false, - }; - if image_understanding_needs_fix { - if let Some(current) = config.ai.default_models.image_understanding.as_deref() { - classify_invalid(current, &mut invalidated); - } - - match image_understanding_fallback_id.as_ref() { - Some(new_id) => { - info!( - "Reconcile ({caller}): default_models.image_understanding repointed: {:?} -> {}", - config.ai.default_models.image_understanding, new_id - ); - config.ai.default_models.image_understanding = Some(new_id.clone()); - } - None => { - info!( - "Reconcile ({caller}): default_models.image_understanding cleared (no enabled capable model available); previous={:?}", - config.ai.default_models.image_understanding - ); - config.ai.default_models.image_understanding = None; - } - } - default_models_changed = true; - } - - // Ensure `invalidated` doesn't contain a still-existing-and-enabled ID. - invalidated.retain(|id| !enabled_ids.contains(id)); - - // Persist any changes. We deliberately use the inner manager (and not - // `self.set_config`) to avoid triggering a recursive reconcile pass. - if func_agent_models_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.func_agent_models", &config.ai.func_agent_models) - .await?; - } - if agent_model_defaults_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.agent_model_defaults", &config.ai.agent_model_defaults) - .await?; - } - if default_models_changed { - let mut manager = self.manager.write().await; - manager - .set("ai.default_models", &config.ai.default_models) - .await?; + if !reconciliation.is_noop() { + self.manager.write().await.set("", &config).await?; } let report = ReconcileModelsReport { - invalidated_model_ids: invalidated.into_iter().collect(), - default_models_changed, - func_agent_models_changed, - agent_model_defaults_changed, + invalidated_model_ids: reconciliation.invalidated_model_ids, + default_models_changed: reconciliation.default_models_changed, + func_agent_models_changed: reconciliation.func_agent_models_changed, + agent_model_defaults_changed: reconciliation.agent_model_defaults_changed, }; if report.is_noop() { @@ -755,6 +664,217 @@ mod tests { assert!(current["mcpServers"].get("stale").is_none()); } + #[tokio::test] + async fn startup_repairs_speech_sentinels_and_creates_a_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let user_root = dir.path().join("speech-startup-repair"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); + path_manager + .initialize_user_directories() + .await + .expect("user directories"); + let mut config = GlobalConfig::default(); + config.ai.models.push(AIModelConfig { + id: "speech".to_string(), + name: "Qwen ASR".to_string(), + provider: "openai".to_string(), + model_name: "qwen-asr".to_string(), + base_url: "https://example.com/v1".to_string(), + api_key: "secret".to_string(), + enabled: true, + category: ModelCategory::SpeechRecognition, + capabilities: vec![ModelCapability::SpeechRecognition], + context_window: Some(0), + max_tokens: Some(0), + ..Default::default() + }); + config.ai.default_models.speech_recognition = Some("speech".to_string()); + tokio::fs::write( + path_manager.app_config_file(), + serde_json::to_vec_pretty(&config).expect("serialize config"), + ) + .await + .expect("seed config"); + + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 5, + }) + .await + .expect("config service should recover"); + + let repaired: GlobalConfig = service.get_config(None).await.expect("repaired config"); + let speech = repaired + .ai + .models + .iter() + .find(|model| model.id == "speech") + .expect("speech model"); + assert_eq!(speech.context_window, None); + assert_eq!(speech.max_tokens, None); + assert_eq!( + repaired.ai.default_models.speech_recognition.as_deref(), + Some("speech") + ); + assert!(service + .load_diagnostics() + .await + .iter() + .any(|diagnostic| diagnostic.code == "MODEL_FIELD_NOT_APPLICABLE")); + let backups = std::fs::read_dir(path_manager.user_config_dir().join("backups")) + .expect("backup directory") + .collect::, _>>() + .expect("backup entries"); + assert_eq!(backups.len(), 1); + } + + #[tokio::test] + async fn malformed_json_uses_in_memory_defaults_and_preserves_the_original() { + let dir = tempfile::tempdir().expect("tempdir"); + let user_root = dir.path().join("invalid-json-recovery"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); + path_manager + .initialize_user_directories() + .await + .expect("user directories"); + let broken = "{\"ai\": {\"models\": ["; + tokio::fs::write(path_manager.app_config_file(), broken) + .await + .expect("seed broken config"); + + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 5, + }) + .await + .expect("startup should use defaults"); + + assert_eq!( + tokio::fs::read_to_string(path_manager.app_config_file()) + .await + .expect("original config"), + broken + ); + let diagnostic = service + .load_diagnostics() + .await + .into_iter() + .find(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY") + .expect("recovery diagnostic"); + assert!(!diagnostic.message.contains("api_key")); + let backup = std::fs::read_dir(path_manager.user_config_dir().join("backups")) + .expect("backup directory") + .next() + .expect("backup entry") + .expect("backup path") + .path(); + assert_eq!( + tokio::fs::read_to_string(backup) + .await + .expect("backup content"), + broken + ); + } + + #[tokio::test] + async fn cloud_speech_save_updates_all_owned_fields_in_one_persisted_config() { + let test_name = "atomic-cloud-speech"; + let (service, dir) = test_service(test_name).await; + let result = service + .save_cloud_speech_config(SaveCloudSpeechConfigRequest { + config_id: Some("speech-cloud".to_string()), + preset: "qwen".to_string(), + name: "Qwen ASR".to_string(), + base_url: "https://example.com/v1/".to_string(), + request_url: None, + model_name: "qwen-asr".to_string(), + api_key: "secret".to_string(), + }) + .await + .expect("speech config should save"); + assert!(result.created); + + let path_manager = PathManager::with_user_root_for_tests(dir.path().join(test_name)); + let persisted: GlobalConfig = serde_json::from_slice( + &tokio::fs::read(path_manager.app_config_file()) + .await + .expect("persisted config"), + ) + .expect("valid persisted config"); + let model = persisted + .ai + .models + .iter() + .find(|model| model.id == "speech-cloud") + .expect("speech model"); + assert_eq!(model.context_window, None); + assert_eq!(model.max_tokens, None); + assert_eq!( + model.request_url.as_deref(), + Some("https://example.com/v1/audio/transcriptions") + ); + assert_eq!( + persisted.ai.default_models.speech_recognition.as_deref(), + Some("speech-cloud") + ); + assert_eq!(persisted.app.ai_experience.voice_input.provider, "cloud"); + assert_eq!( + persisted.app.ai_experience.voice_input.model_id, + "speech-cloud" + ); + } + + #[tokio::test] + async fn cloud_speech_save_reconciles_text_references_when_reusing_a_model_id() { + let (service, _dir) = test_service("cloud-speech-reused-id").await; + service + .set_config( + "ai.models", + vec![model("reused-model", true, ModelCategory::GeneralChat)], + ) + .await + .expect("text model should save"); + + let before: GlobalConfig = service.get_config(None).await.expect("config before save"); + assert_eq!( + before.ai.default_models.primary.as_deref(), + Some("reused-model") + ); + assert_eq!( + before.ai.default_models.fast.as_deref(), + Some("reused-model") + ); + + let result = service + .save_cloud_speech_config(SaveCloudSpeechConfigRequest { + config_id: Some("reused-model".to_string()), + preset: "custom".to_string(), + name: "Speech replacement".to_string(), + base_url: "https://example.com/v1".to_string(), + request_url: None, + model_name: "speech-model".to_string(), + api_key: "secret".to_string(), + }) + .await + .expect("speech replacement should save"); + assert!(!result.created); + + let after: GlobalConfig = service.get_config(None).await.expect("config after save"); + assert_eq!(after.ai.default_models.primary, None); + assert_eq!(after.ai.default_models.fast, None); + assert_eq!( + after.ai.default_models.speech_recognition.as_deref(), + Some("reused-model") + ); + assert!(after + .ai + .func_agent_models + .values() + .all(|model_id| { !matches!(model_id.as_str(), "reused-model") })); + } + #[tokio::test] async fn set_config_rejects_invalid_reasoning_and_rolls_back() { let (service, _dir) = test_service("invalid-reasoning-set").await; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 725e9c952e..cfb540d906 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -77,6 +77,10 @@ pub struct GlobalConfig { /// Web UI font size preferences (`get_config` / `set_config` path `font`). #[serde(skip_serializing_if = "Option::is_none")] pub font: Option, + /// Version of the persisted configuration schema. This is intentionally + /// independent from the BitFun application version stored in `version`. + #[serde(default = "default_config_schema_version")] + pub schema_version: u32, pub version: String, #[serde(with = "chrono::serde::ts_milliseconds")] pub last_modified: chrono::DateTime, @@ -336,6 +340,32 @@ impl Default for VoiceInputConfig { } } +/// Domain request for atomically saving a cloud speech-recognition model and +/// selecting it for voice input. Text-generation fields are intentionally not +/// part of this contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_id: Option, + pub preset: String, + pub name: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_url: Option, + pub model_name: String, + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigResult { + pub model_id: String, + pub created: bool, +} + /// AI experience configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -519,6 +549,12 @@ pub enum ModelCapability { SpeechRecognition, } +pub const CURRENT_CONFIG_SCHEMA_VERSION: u32 = 1; + +fn default_config_schema_version() -> u32 { + CURRENT_CONFIG_SCHEMA_VERSION +} + /// Model category (for UI display and filtering). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] @@ -1578,6 +1614,33 @@ pub struct ConfigValidationResult { pub valid: bool, pub errors: Vec, pub warnings: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConfigDiagnosticSeverity { + Error, + Warning, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConfigDiagnosticRecoverability { + None, + AutoFix, + ModelDisabled, + DefaultsUsed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConfigDiagnostic { + pub path: String, + pub message: String, + pub code: String, + pub severity: ConfigDiagnosticSeverity, + pub recoverability: ConfigDiagnosticRecoverability, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1611,6 +1674,7 @@ impl Default for GlobalConfig { acp_clients: None, appearance: AppearanceConfig::default(), font: None, + schema_version: CURRENT_CONFIG_SCHEMA_VERSION, version: "1.0.0".to_string(), last_modified: chrono::Utc::now(), } @@ -1881,11 +1945,44 @@ impl Default for MinimapConfig { } impl AIModelConfig { + pub fn supports_capability(&self, capability: ModelCapability) -> bool { + if self.capabilities.is_empty() { + self.default_capabilities_for_category() + .contains(&capability) + } else { + self.capabilities.contains(&capability) + } + } + + pub fn supports_text_generation(&self) -> bool { + self.supports_capability(ModelCapability::TextChat) + } + + /// Canonicalizes fields that only have meaning for text-generation + /// requests. Returns the names of fields that were cleared. + pub fn normalize_inapplicable_generation_fields(&mut self) -> Vec<&'static str> { + if self.supports_text_generation() { + return Vec::new(); + } + + let mut cleared = Vec::new(); + if self.context_window.take().is_some() { + cleared.push("context_window"); + } + if self.max_tokens.take().is_some() { + cleared.push("max_tokens"); + } + if self.temperature.take().is_some() { + cleared.push("temperature"); + } + if self.top_p.take().is_some() { + cleared.push("top_p"); + } + cleared + } + pub fn supports_image_understanding(&self) -> bool { - self.capabilities - .iter() - .any(|cap| matches!(cap, ModelCapability::ImageUnderstanding)) - || matches!(self.category, ModelCategory::Multimodal) + self.supports_capability(ModelCapability::ImageUnderstanding) } /// Legacy helper that infers the model category from the model name and provider. diff --git a/src/crates/assembly/core/src/util/types/config.rs b/src/crates/assembly/core/src/util/types/config.rs index 0dc0960c23..f959aa6418 100644 --- a/src/crates/assembly/core/src/util/types/config.rs +++ b/src/crates/assembly/core/src/util/types/config.rs @@ -74,6 +74,13 @@ impl TryFrom for AIConfig { type Error = String; fn try_from(other: AIModelConfig) -> Result { + if !other.supports_text_generation() { + return Err(format!( + "Model '{}' does not support text_chat and cannot be used for text generation", + other.name + )); + } + let custom_request_body = if let Some(body_str) = &other.custom_request_body { match serde_json::from_str::(body_str) { Ok(value) => Some(value), @@ -156,7 +163,7 @@ impl TryFrom for AIConfig { #[cfg(test)] mod tests { use super::{resolve_request_url, AIConfig}; - use crate::service::config::types::{AIModelConfig, ModelCategory}; + use crate::service::config::types::{AIModelConfig, ModelCapability, ModelCategory}; #[test] fn resolves_openai_request_url() { @@ -313,4 +320,17 @@ mod tests { assert!(error.contains("at least 32000")); } + + #[test] + fn rejects_pure_speech_models_at_the_text_generation_boundary() { + let mut model = base_model_config(); + model.category = ModelCategory::SpeechRecognition; + model.capabilities = vec![ModelCapability::SpeechRecognition]; + model.context_window = None; + model.max_tokens = None; + + let error = AIConfig::try_from(model).expect_err("speech model is not a chat model"); + + assert!(error.contains("does not support text_chat")); + } } diff --git a/src/crates/interfaces/app-server-client/Cargo.toml b/src/crates/interfaces/app-server-client/Cargo.toml index 0b4e6131dc..c9cbbd936a 100644 --- a/src/crates/interfaces/app-server-client/Cargo.toml +++ b/src/crates/interfaces/app-server-client/Cargo.toml @@ -12,6 +12,7 @@ name = "bitfun_app_server_client" agent-client-protocol = { workspace = true } anyhow = { workspace = true } bitfun-app-server-protocol = { path = "../app-server-protocol" } +serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } [lints] diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index e8e638b796..e36783f081 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -7,6 +7,10 @@ use agent_client_protocol::{ConnectTo, ConnectionTo, JsonRpcResponse, SentReques use bitfun_app_server_protocol::app::{ HealthRequest, HealthResponse, InitializeRequest, InitializeResponse, }; +use bitfun_app_server_protocol::config::{ + SaveCloudSpeechConfigMessage, SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResponse, + SaveCloudSpeechConfigResult, ValidateConfigMessage, ValidateConfigResponse, +}; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::event::{ AgentEventNotification, ConfigEventNotification, EventStreamStateNotification, @@ -68,6 +72,26 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(HealthRequest {}))).await } + pub async fn save_cloud_speech_config( + &self, + request: SaveCloudSpeechConfigRequest, + ) -> Result { + let SaveCloudSpeechConfigResponse(result) = self + .request_with_timeout( + |cx| Ok(cx.send_request(SaveCloudSpeechConfigMessage { request })), + SIDE_EFFECT_TIMEOUT, + ) + .await?; + Ok(result) + } + + pub async fn validate_config(&self) -> agent_client_protocol::Result { + let ValidateConfigResponse(result) = self + .rpc(|cx| Ok(cx.send_request(ValidateConfigMessage {}))) + .await?; + Ok(result) + } + pub async fn tui_model_catalog( &self, ) -> agent_client_protocol::Result { diff --git a/src/crates/interfaces/app-server-protocol/src/config.rs b/src/crates/interfaces/app-server-protocol/src/config.rs new file mode 100644 index 0000000000..c30fc57dba --- /dev/null +++ b/src/crates/interfaces/app-server-protocol/src/config.rs @@ -0,0 +1,72 @@ +//! Configuration wire contracts shared by App Server hosts and clients. +//! +//! These payloads intentionally contain only wire-owned data. Server adapters +//! translate them to the configuration service's domain request/result types. + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_id: Option, + pub preset: String, + pub name: String, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_url: Option, + pub model_name: String, + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[request(method = "config/saveCloudSpeechConfig", response = SaveCloudSpeechConfigResponse)] +pub struct SaveCloudSpeechConfigMessage { + pub request: SaveCloudSpeechConfigRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct SaveCloudSpeechConfigResult { + pub model_id: String, + pub created: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +pub struct SaveCloudSpeechConfigResponse(pub SaveCloudSpeechConfigResult); + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "config/validateConfig", response = ValidateConfigResponse)] +pub struct ValidateConfigMessage {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +pub struct ValidateConfigResponse(pub serde_json::Value); + +#[cfg(test)] +mod tests { + use super::SaveCloudSpeechConfigRequest; + + #[test] + fn cloud_speech_request_uses_the_camel_case_wire_shape() { + let value = serde_json::to_value(SaveCloudSpeechConfigRequest { + config_id: Some("speech".to_string()), + preset: "custom".to_string(), + name: "Speech".to_string(), + base_url: "https://example.com/v1".to_string(), + request_url: None, + model_name: "speech-model".to_string(), + api_key: "secret".to_string(), + }) + .expect("request should serialize"); + + assert_eq!(value["configId"], "speech"); + assert_eq!(value["baseUrl"], "https://example.com/v1"); + assert_eq!(value["modelName"], "speech-model"); + assert!(value.get("requestUrl").is_none()); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index 221d464ff0..097354530d 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -5,6 +5,7 @@ //! these wire DTOs to owner types at the interface boundary. pub mod app; +pub mod config; pub mod error; pub mod event; pub mod method; diff --git a/src/crates/interfaces/app-server/src/schema/config.rs b/src/crates/interfaces/app-server/src/schema/config.rs index 5827c087cb..20b3ac4c84 100644 --- a/src/crates/interfaces/app-server/src/schema/config.rs +++ b/src/crates/interfaces/app-server/src/schema/config.rs @@ -1,6 +1,11 @@ use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; +pub use bitfun_app_server_protocol::config::{ + SaveCloudSpeechConfigMessage, SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResponse, + SaveCloudSpeechConfigResult, ValidateConfigMessage, ValidateConfigResponse, +}; + #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[request(method = "config/getAgentProfileConfigs", response = GetAgentProfileConfigsResponse)] diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 372c110fdd..a33e7fee8e 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -159,6 +159,8 @@ fn registered_capabilities() -> Vec { "config/getConfig", "config/getConfigs", "config/setConfig", + "config/saveCloudSpeechConfig", + "config/validateConfig", "config/setAgentProfileConfig", "config/resetAgentProfileConfig", ], diff --git a/src/crates/interfaces/app-server/src/server/handlers/config.rs b/src/crates/interfaces/app-server/src/server/handlers/config.rs index e3aa7115e9..bbd13d5500 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/config.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/config.rs @@ -94,6 +94,50 @@ pub(in crate::server) fn builder() -> Builder { + if (!interactiveShellReady) { + return; + } + + let cancelled = false; + void (async () => { + try { + const { configAPI } = await import('@/infrastructure/api'); + const validation = await configAPI.validateConfig(); + const recoveryDiagnostics = (validation.diagnostics || []).filter(diagnostic => + diagnostic.code === 'CONFIG_DEFAULT_RECOVERY' || + diagnostic.code === 'CONFIG_SHAPE_REPAIRED' || + diagnostic.code === 'INVALID_MODEL_DISABLED' || + diagnostic.code === 'MODEL_FIELD_NOT_APPLICABLE' || + diagnostic.code === 'MODEL_REFERENCE_REPAIRED' + ); + if (cancelled || recoveryDiagnostics.length === 0) { + return; + } + const recoveryKey = `bitfun:config-recovery-notice:${recoveryDiagnostics + .map(diagnostic => `${diagnostic.code}:${diagnostic.path}`) + .join('|')}`; + if (sessionStorage.getItem(recoveryKey) === 'shown') { + return; + } + sessionStorage.setItem(recoveryKey, 'shown'); + notificationService.warning(t('logging.configRecovery.message', { + count: recoveryDiagnostics.length, + }), { + title: t('logging.configRecovery.title'), + duration: 0, + metadata: { + source: 'config-startup-recovery', + diagnosticCodes: recoveryDiagnostics.map(diagnostic => diagnostic.code), + diagnosticPaths: recoveryDiagnostics.map(diagnostic => diagnostic.path), + }, + }); + } catch (error) { + log.warn('Failed to check configuration recovery status', error); + } + })(); + + return () => { + cancelled = true; + }; + }, [interactiveShellReady, t]); + // Unified layout via a single AppLayout return ( diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts index dc16e2f65d..b687def46a 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -108,6 +108,10 @@ describe('resolveWsMethod', () => { 'config/resetAgentProfileConfig' ); expect(resolveWsMethod('set_config')).toBe('config/setConfig'); + expect(resolveWsMethod('save_cloud_speech_config')).toBe( + 'config/saveCloudSpeechConfig' + ); + expect(resolveWsMethod('validate_config')).toBe('config/validateConfig'); expect(resolveWsMethod('i18n_get_current_language')).toBe( 'i18n/getCurrentLanguage' ); @@ -134,11 +138,12 @@ describe('resolveWsMethod', () => { // Runtime sanity: the schema entry carries the method string and the table // covers the schema methods (key count is stable; ordering is not pinned // because the table is a plain object). Track B Batch 1 added config write + - // i18n and the P0 Session/Config control plane, raising the count to 31. + // i18n and the P0 Session/Config control plane. Atomic cloud-speech save + // and config validation raise the count to 33. expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( 'agent/submitDialogTurn' ); - expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(31); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(33); // Touch the locals so noUnusedLocals does not flag them under vitest's // transformed build (tsc --noEmit is the real gate; this is belt-and-suspenders). diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 122ebc7829..98194d740e 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -171,6 +171,8 @@ export const AGENT_COMMAND_SCHEMA = { response: null as unknown as ResetAgentProfileConfigResponse, }, set_config: { method: 'config/setConfig' }, + save_cloud_speech_config: { method: 'config/saveCloudSpeechConfig' }, + validate_config: { method: 'config/validateConfig' }, i18n_get_current_language: { method: 'i18n/getCurrentLanguage' }, i18n_set_language: { method: 'i18n/setLanguage' }, i18n_get_config: { method: 'i18n/getConfig' }, diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts index d5328ffdc8..d07b621601 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts @@ -71,4 +71,28 @@ describe('ConfigAPI batch config reads', () => { }, }, undefined); }); + + it('saves cloud speech configuration through one domain command', async () => { + invokeMock.mockResolvedValueOnce({ modelId: 'speech-1', created: true }); + + await expect(configAPI.saveCloudSpeechConfig({ + preset: 'qwen', + name: 'Qwen ASR', + baseUrl: 'https://example.com/v1', + requestUrl: 'https://example.com/v1/audio/transcriptions', + modelName: 'qwen-asr', + apiKey: 'secret', + })).resolves.toEqual({ modelId: 'speech-1', created: true }); + + expect(invokeMock).toHaveBeenCalledWith('save_cloud_speech_config', { + request: { + preset: 'qwen', + name: 'Qwen ASR', + baseUrl: 'https://example.com/v1', + requestUrl: 'https://example.com/v1/audio/transcriptions', + modelName: 'qwen-asr', + apiKey: 'secret', + }, + }); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts index cd953cbb27..c25e5ecf16 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts @@ -8,12 +8,19 @@ import type { GlobalSkillSettings, ModeSkillInfo, RuntimeLoggingInfo, + ConfigValidationResult, SkillInfo, SkillLevel, SkillMarketDownloadResult, SkillMarketItem, SkillValidationResult, } from '../../config/types'; +import type { + SaveCloudSpeechConfigRequest, + SaveCloudSpeechConfigResult, +} from '@/generated/api'; + +export type { SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResult } from '@/generated/api'; export interface GetSkillConfigsParams { forceRefresh?: boolean; @@ -136,6 +143,27 @@ export class ConfigAPI { } } + async saveCloudSpeechConfig( + request: SaveCloudSpeechConfigRequest + ): Promise { + try { + return await api.invoke('save_cloud_speech_config', { request }); + } catch (error) { + throw createTauriCommandError('save_cloud_speech_config', error, { + ...request, + apiKey: request.apiKey ? '[redacted]' : '', + }); + } + } + + async validateConfig(): Promise { + try { + return await api.invoke('validate_config'); + } catch (error) { + throw createTauriCommandError('validate_config', error); + } + } + async resetConfig(path?: string): Promise { try { diff --git a/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx index 5425582e46..c43e093da2 100644 --- a/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx @@ -351,51 +351,30 @@ const VoiceInputConfig: React.FC = () => { setBusyAction('saveCloudModel'); try { - const allModels = await configManager.getConfig('ai.models') || []; const modelId = cloudDraft.configId || selectedCloudModel?.id || `speech_cloud_${Date.now()}`; - const nextModel: AIModelConfig = { - id: modelId, + const result = await configManager.saveCloudSpeechConfig({ + configId: modelId, + preset: cloudDraft.preset, name, - provider: 'openai', - api_key: apiKey, - base_url: baseUrl, - request_url: resolveTranscriptionRequestUrl(baseUrl), - model_name: modelName, - context_window: 0, - max_tokens: 0, - enabled: true, - category: 'speech_recognition', - capabilities: ['speech_recognition'], - recommended_for: ['voice_input'], - metadata: { - ...(selectedCloudModel?.metadata || {}), - speech_provider_preset: cloudDraft.preset, - }, - auth: { type: 'api_key' }, - }; - const replaced = allModels.some(model => model.id === modelId); - const nextModels = replaced - ? allModels.map(model => model.id === modelId ? nextModel : model) - : [...allModels, nextModel]; - const currentDefaultModels = await configManager.getConfig('ai.default_models') || {}; - - await configManager.setConfig('ai.models', nextModels); - await configManager.setConfig('ai.default_models', { - ...currentDefaultModels, - speech_recognition: modelId, + baseUrl, + requestUrl: resolveTranscriptionRequestUrl(baseUrl), + modelName, + apiKey, }); - await updateVoiceInput({ - provider: 'cloud', - model_id: modelId, - }, { silent: true }); - setCloudDraft(createCloudSpeechDraftFromModel(nextModel)); - setCloudModels(nextModels.filter(model => { + const [nextModels, nextDefaultModels] = await Promise.all([ + configManager.getConfig('ai.models'), + configManager.getConfig('ai.default_models'), + ]); + const savedModel = (nextModels || []).find(model => model.id === result.modelId); + setCloudDraft(createCloudSpeechDraftFromModel(savedModel)); + setCloudModels((nextModels || []).filter(model => { const capabilities = Array.isArray(model.capabilities) ? model.capabilities : []; return !!model.enabled && ( model.category === 'speech_recognition' || capabilities.includes('speech_recognition') ); })); + setDefaultModels(nextDefaultModels || {}); notificationService.success(t('cloudConfig.messages.saveSuccess')); } catch (error) { log.error('Failed to save cloud speech model', { error }); @@ -403,7 +382,7 @@ const VoiceInputConfig: React.FC = () => { } finally { setBusyAction(null); } - }, [cloudDraft, selectedCloudModel, t, updateVoiceInput]); + }, [cloudDraft, selectedCloudModel, t]); const handleDownload = useCallback((model: SpeechModelStatus) => { if (model.state === 'downloading') return; diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index cb6acd9991..9d7f17d1cd 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -603,6 +603,22 @@ class ConfigManagerImpl implements IConfigManager { } } + async saveCloudSpeechConfig( + request: import('@/infrastructure/api/service-api/ConfigAPI').SaveCloudSpeechConfigRequest + ): Promise { + let result: import('@/infrastructure/api/service-api/ConfigAPI').SaveCloudSpeechConfigResult | undefined; + await this.runMutation(undefined, async () => { + result = await configAPI.saveCloudSpeechConfig(request); + }, () => { + this.notifyConfigChange('ai', undefined, undefined); + this.notifyConfigChange('app.ai_experience', undefined, undefined); + }); + if (!result) { + throw new Error('Cloud speech configuration save returned no result'); + } + return result; + } + async resetConfig(path?: string): Promise { try { await this.runMutation(path, () => configAPI.resetConfig(path)); @@ -614,10 +630,7 @@ class ConfigManagerImpl implements IConfigManager { async validateConfig(): Promise { try { - - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('validate_config'); - return result; + return await configAPI.validateConfig(); } catch (error) { log.error('Failed to validate config', error); return { diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 631e8f4661..330e56338d 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -658,6 +658,15 @@ export interface ConfigValidationResult { valid: boolean; errors: ConfigValidationError[]; warnings: ConfigValidationWarning[]; + diagnostics?: ConfigDiagnostic[]; +} + +export interface ConfigDiagnostic { + path: string; + message: string; + code: string; + severity: 'error' | 'warning'; + recoverability: 'none' | 'auto_fix' | 'model_disabled' | 'defaults_used'; } export interface ConfigValidationError { diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index 7f7d38d3eb..ddf7d13fd4 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -147,6 +147,10 @@ "title": "Previous session ended unexpectedly", "message": "BitFun found a crash report from the previous session. You can export diagnostics if you want to report the issue." }, + "configRecovery": { + "title": "Configuration recovered", + "message": "BitFun repaired or isolated {{count}} configuration issue(s) so the app could start. Review your model settings before continuing." + }, "levels": { "trace": "Trace", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 8f26d5a369..2df39b45d0 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -147,6 +147,10 @@ "title": "上次会话异常结束", "message": "BitFun 发现上次会话留下了崩溃报告。如需反馈问题,可以导出诊断包。" }, + "configRecovery": { + "title": "配置已恢复", + "message": "BitFun 已修复或隔离 {{count}} 个配置问题,应用得以继续启动。请检查模型设置后再继续使用。" + }, "levels": { "trace": "Trace", "debug": "Debug", diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index 95655053fc..74cfb41d20 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -133,6 +133,10 @@ "title": "上次會話異常結束", "message": "BitFun 發現上次會話留下了崩潰報告。如需回報問題,可以匯出診斷包。" }, + "configRecovery": { + "title": "設定已復原", + "message": "BitFun 已修復或隔離 {{count}} 個設定問題,應用程式得以繼續啟動。請檢查模型設定後再繼續使用。" + }, "levels": { "trace": "Trace", "debug": "Debug", From 9ec400040448e70c43994a6dd10ae997d079f9d1 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 10:59:17 +0800 Subject: [PATCH 038/206] fix(desktop): prevent Windows debug startup stack overflow - Reserve an 8 MiB stack for the Windows desktop process entry thread. - Set RUST_MIN_STACK before constructing the Tokio runtime. - Preserve configuration recovery and cloud speech command behavior. --- src/apps/desktop/build.rs | 5 +++++ src/apps/desktop/src/main.rs | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index 261851f6b6..d84e5ec5db 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,3 +1,8 @@ fn main() { + // The Windows primary thread keeps the Tauri event loop and native window + // creation stack. Reserve the same headroom as the Tokio workers so a + // large debug invoke dispatcher cannot exhaust the default 1 MiB stack. + #[cfg(target_os = "windows")] + println!("cargo:rustc-link-arg-bins=/STACK:8388608"); tauri_build::build(); } diff --git a/src/apps/desktop/src/main.rs b/src/apps/desktop/src/main.rs index d230b69a75..af5c4c0261 100644 --- a/src/apps/desktop/src/main.rs +++ b/src/apps/desktop/src/main.rs @@ -2,8 +2,14 @@ // plugin redirects them back to the existing desktop process. #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() { - std::env::set_var("RUST_MIN_STACK", "8388608"); // 8MB - bitfun_desktop_lib::run().await +fn main() { + // Tokio reads this value while creating its worker threads. Setting it in + // the async body is too late, because the runtime has already been built. + std::env::set_var("RUST_MIN_STACK", "8388608"); // 8 MiB worker stacks + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("failed to build Tokio runtime"); + runtime.block_on(bitfun_desktop_lib::run()); } From 053fc045a573a8fddeaddb4e96fcd62adbfe0d24 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 7 Aug 2026 11:33:25 +0800 Subject: [PATCH 039/206] fix(web): generate app-server protocol bindings in CI - Export TypeScript bindings from bitfun-app-server-protocol before app-server bindings. - Require SaveCloudSpeechConfigRequest and SaveCloudSpeechConfigResult in the API barrel. - Prevent stale local generated files from masking missing CI bindings. --- src/web-ui/package.json | 2 +- src/web-ui/scripts/gen-api-barrel.mjs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 664c19c6ba..86a5b8dc6e 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "dev:force": "vite --force", - "gen:types": "cargo test --package bitfun-app-server --features ts --no-default-features export -- --nocapture && node scripts/gen-api-barrel.mjs", + "gen:types": "cargo test --package bitfun-app-server-protocol --features ts export -- --nocapture && cargo test --package bitfun-app-server --features ts --no-default-features export -- --nocapture && node scripts/gen-api-barrel.mjs", "build": "vite build", "build:desktop": "vite build --mode desktop", "build:web": "vite build --mode web", diff --git a/src/web-ui/scripts/gen-api-barrel.mjs b/src/web-ui/scripts/gen-api-barrel.mjs index d49b1e5d2f..7f32f0423d 100644 --- a/src/web-ui/scripts/gen-api-barrel.mjs +++ b/src/web-ui/scripts/gen-api-barrel.mjs @@ -23,7 +23,11 @@ const files = (await readdir(dir, { withFileTypes: true })) .map((e) => basename(e.name, extname(e.name))) .sort(); -const requiredTypes = ['ConfigUpdate']; +const requiredTypes = [ + 'ConfigUpdate', + 'SaveCloudSpeechConfigRequest', + 'SaveCloudSpeechConfigResult', +]; const missingTypes = requiredTypes.filter((typeName) => !files.includes(typeName)); if (missingTypes.length > 0) { throw new Error( From 9dff7e00a33c2f8e647d952e3d150334e2634194 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 21:29:53 -0700 Subject: [PATCH 040/206] fix(dispatch): stop PTY teardown from killing one-click CLI installs The SSH CLI installer launched its driver over a PTY exec channel. The driver only spawns a nohup body and exits, which it does about a millisecond later, and sshd tears the PTY down the moment it does. That teardown races the body: a body still inside bash's startup has not reached its own exit trap yet, so losing the race kills it silently. Nothing survives to explain it. The body writes no log and no exit file, the driver's cleanup trap removes the `.preparing` marker, and the next poll reaps the now-stale `.pid`. The controller reads an empty state, maps it to Failed on its very first poll, and the user gets "could not fully deploy BitFun" for an install that had already downloaded and verified the release. Launch over a plain exec channel instead. Without a controlling terminal there is no hangup to race, and the installer needs no TTY semantics anyway since it never uses sudo. Two diagnostics gaps kept this invisible and are fixed alongside it: - WebKit, which Tauri embeds on macOS, builds `Error.stack` from frames only. The logger preferred `stack` over the message, so the warning reached the log file as a bare source location with no reason. - The install poll's failure discarded the installer's own output, so even a populated remote log never reached the error. Verified against a real Ubuntu aarch64 target. Launching a detached process from a PTY channel is killed before it writes a line; over a plain channel it survives and the install completes (running=1 on the first poll, then marker=1 exit_code=0). --- .../src/remote_ssh/dispatch_ssh.rs | 18 ++++++--- .../dispatch/DispatchInstallDialog.tsx | 10 ++++- src/web-ui/src/shared/utils/logger.test.ts | 37 +++++++++++++++++++ src/web-ui/src/shared/utils/logger.ts | 21 +++++++++-- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index b1953e707a..40ddc703c9 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -846,19 +846,25 @@ async fn stage_and_launch_installer( ) .await?; - // The short-lived PTY driver only starts a nohup body and exits. Draining - // the channel in the background prevents a server-side channel leak while - // keeping the installer independent of the caller process. + // The short-lived driver only starts a nohup body and exits, and it must run + // without a PTY. sshd tears a PTY down as soon as the driver exits — about a + // millisecond after the hand-off — and that teardown races the body it just + // spawned. A body still inside bash's startup has not reached its own exit + // trap yet, so losing that race kills it silently: no log, no exit file, and + // a `.pid` the next poll then reaps as stale. The controller sees an empty + // state and reports the install as failed even though nothing went wrong. + // A plain exec channel has no controlling terminal, so the hand-off cannot be + // interrupted; the installer needs no TTY semantics either, since it never + // uses sudo. Draining the channel in the background prevents a server-side + // channel leak while keeping the installer independent of the caller process. let channel = match manager - .open_pty_exec_channel( + .open_exec_channel( connection_id, &format!( "bash {} {}", shell_quote_posix(script_path), shell_quote_posix(install_token) ), - 100, - 30, ) .await { diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index a777060fa5..a3c614c2ce 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -235,7 +235,15 @@ export const DispatchInstallDialog: React.FC = ({ cursor = poll.cursor; if (poll.status === 'succeeded') break; if (poll.status === 'failed') { - throw new Error('Target CLI installation failed'); + // Carry the installer's own tail into the error: without it the only + // record of the failure is a generic message, and the remote state + // that would explain it is cleaned up before anyone can look. + const detail = poll.output.trim(); + throw new Error( + detail + ? `Target CLI installation failed: ${detail}` + : 'Target CLI installation failed with no installer output', + ); } await new Promise(resolve => globalThis.setTimeout(resolve, 750)); } diff --git a/src/web-ui/src/shared/utils/logger.test.ts b/src/web-ui/src/shared/utils/logger.test.ts index e1acc21db7..4fcab680b3 100644 --- a/src/web-ui/src/shared/utils/logger.test.ts +++ b/src/web-ui/src/shared/utils/logger.test.ts @@ -18,6 +18,43 @@ async function importLoggerWithBootstrapLevel(level: unknown) { return import('./logger'); } +describe('error formatting', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // WebKit — what Tauri embeds on macOS — builds `stack` from frames only, so a + // logged failure would otherwise arrive with a location and no reason. + it('keeps the message when the stack omits it', async () => { + const { createLogger } = await importLoggerWithBootstrapLevel('debug'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('Target CLI installation failed'); + error.stack = '@tauri://localhost/assets/ChatPane.js:96:14836'; + + createLogger('DispatchInstallDialog').warn('Failed to prepare SSH dispatch target', { + connectionId: 'ssh-a', + error, + }); + + const line = String(warn.mock.calls.at(-1)?.[0] ?? ''); + expect(line).toContain('Target CLI installation failed'); + expect(line).toContain('ChatPane.js:96:14836'); + expect(line).toContain('"connectionId":"ssh-a"'); + }); + + it('does not repeat the message when the stack already carries it', async () => { + const { createLogger } = await importLoggerWithBootstrapLevel('debug'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('boom'); + error.stack = 'Error: boom\n at somewhere'; + + createLogger('Ctx').warn('failed', error); + + const line = String(warn.mock.calls.at(-1)?.[0] ?? ''); + expect(line).toBe('[Ctx] failed Error: boom\n at somewhere'); + }); +}); + describe('logger bootstrap level', () => { afterEach(() => { delete globalThis.__BITFUN_BOOTSTRAP_LOG_LEVEL__; diff --git a/src/web-ui/src/shared/utils/logger.ts b/src/web-ui/src/shared/utils/logger.ts index 24649cff7e..c77693949f 100644 --- a/src/web-ui/src/shared/utils/logger.ts +++ b/src/web-ui/src/shared/utils/logger.ts @@ -49,6 +49,21 @@ export function areSensitiveDiagnosticsEnabled(): boolean { return includeSensitiveDiagnostics; } +/** + * Render an Error without losing its message. + * + * `stack` alone is not enough: WebKit — which is what Tauri embeds on macOS — + * builds a stack out of frames only, so `error.stack` for `new Error('boom')` is + * just `@app.js:1:2`. Logging that leaves a failure with a location and no + * reason, which is exactly the case a warning exists to explain. + */ +function formatError(error: Error): string { + const summary = `${error.name}: ${error.message}`; + const stack = error.stack; + if (!stack) return summary; + return stack.includes(error.message) ? stack : `${summary}\n${stack}`; +} + function formatConsoleArg(value: unknown): string { if (value === undefined) return 'undefined'; if (value === null) return 'null'; @@ -57,7 +72,7 @@ function formatConsoleArg(value: unknown): string { return String(value); } if (typeof value === 'symbol') return value.toString(); - if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`; + if (value instanceof Error) return formatError(value); if (typeof value === 'object') { try { return JSON.stringify(value); @@ -229,7 +244,7 @@ export async function initLogger(): Promise { function formatData(data: unknown): string { if (data === undefined || data === null) return ''; if (data instanceof Error) { - return data.stack || data.message; + return formatError(data); } if (typeof data === 'object') { try { @@ -240,7 +255,7 @@ function formatData(data: unknown): string { for (const key of Object.keys(data as Record)) { const value = (data as Record)[key]; if (value instanceof Error) { - errors.push(value.stack || `${value.name}: ${value.message}`); + errors.push(formatError(value)); } else { regularData[key] = value; } From 9c3d23df0b9ebaf89db06c1efa62fcec387a0281 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 22:53:50 -0700 Subject: [PATCH 041/206] fix(dispatch): give every job of one workspace its own target worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a second Dispatch session on a workspace failed provisioning: dispatch __workspace_provision failed (exit 1): Error: dispatch workspace provisioning failed: dispatch worktree exists without the requested base commit The target names a job's checkout `-`, and took the short id by filtering the job id to alphanumerics and slicing the first eight. Job ids are minted as `dispatch-`, so the slice never reached the uuid: every job of a project produced the same eight characters — `dispatch` — and therefore the same directory. The second session landed on the first session's checkout. Provisioning inspects an occupied directory before it fetches anything, so the second job's base commit was usually not in the shared clone yet and it bailed with the message above; when the commit was already cached it bailed on the branch check instead. Either way one workspace could only ever run one dispatch at a time, and the error named neither the directory nor the job it collided with. Digest the job id instead of slicing it. A digest depends on the whole id, so no shared prefix, suffix, or length can collapse two jobs onto one directory. Jobs that already have a checkout keep it: the provision record pins the path it was first given, so this is inert on upgrade. The three worktree-rejection errors now name the directory they judged and the commit or branch they wanted, because an occupied checkout is exactly the case where "which directory, and whose?" is the question. Verified by reverting the fix: both new tests fail, the end-to-end one with the same refusal users hit. --- src/apps/cli/src/dispatch/workspace.rs | 231 ++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 22 deletions(-) diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index bcefb1680e..1e1a855ff6 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -20,6 +20,7 @@ use anyhow::{bail, Context, Result}; use base64::Engine as _; use bitfun_services_core::dispatch_workspace::sha256_file; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use super::protocol::{ DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleBeginResponse, @@ -41,7 +42,7 @@ const BUNDLE_RECORD_FILE: &str = "bundle.json"; const SYNC_OPERATION_FILE: &str = "sync-operation.json"; const INCOMING_BUNDLE_FILE: &str = "incoming.bundle"; const RESULT_BUNDLE_FILE: &str = "result.bundle"; -/// Short job-id suffix that keeps two dispatches of one project apart, matching +/// Short per-job suffix that keeps two dispatches of one project apart, matching /// the local managed-worktree convention. const WORKTREE_SUFFIX_CHARS: usize = 8; /// Upper bound on the readable half of a worktree directory name. @@ -1219,8 +1220,16 @@ fn existing_worktree( quarantine_partial_directory(worktree_path, "worktree")?; return Ok(None); } + // Everything below reports the directory it is judging. A dispatch worktree + // is only ever reached through a name derived from the job, so an occupant + // that fails these checks is either another job's checkout or a hand-edited + // one — and naming which is which is the whole difference between a + // recoverable report and a dead end. if !commit_exists(worktree_path, base_commit)? { - bail!("dispatch worktree exists without the requested base commit"); + bail!( + "dispatch worktree {} exists without the requested base commit {base_commit}", + worktree_path.display() + ); } let current_branch = git( worktree_path, @@ -1231,7 +1240,8 @@ fn existing_worktree( .to_string(); if current_branch != branch { bail!( - "dispatch worktree is on branch '{current_branch}' instead of its managed branch '{branch}'" + "dispatch worktree {} is on branch '{current_branch}' instead of its managed branch '{branch}'", + worktree_path.display() ); } let head = git(worktree_path, &["rev-parse", "HEAD"])?; @@ -1239,7 +1249,10 @@ fn existing_worktree( worktree_path, &["merge-base", "--is-ancestor", base_commit, head.trim()], )? { - bail!("existing dispatch worktree does not descend from its requested base commit"); + bail!( + "dispatch worktree {} does not descend from its requested base commit {base_commit}", + worktree_path.display() + ); } Ok(Some(canonical_utf8(worktree_path)?)) } @@ -1370,6 +1383,13 @@ fn create_worktree( /// advisory input from the controller, so it is sanitized here and falls back to /// the remote URL's basename and finally to a constant — the path must never be /// shaped by an untrusted string. +/// +/// The suffix digests the job id rather than slicing it. Job ids are minted as +/// `dispatch-`, so the leading alphanumerics every id shares — `dispatch` +/// — were all that survived the slice, and every job of one project resolved to +/// the same directory. The second session to start then met the first session's +/// checkout and provisioning refused it. A digest depends on the whole id, so no +/// shared prefix, suffix, or length can collapse two jobs onto one directory. fn worktree_directory_name( project_label: Option<&str>, remote_url: Option<&str>, @@ -1378,18 +1398,25 @@ fn worktree_directory_name( let label = sanitize_label(project_label.unwrap_or_default()) .or_else(|| sanitize_label(&remote_basename(remote_url.unwrap_or_default()))) .unwrap_or_else(|| "workspace".to_string()); - let suffix = job_id - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .take(WORKTREE_SUFFIX_CHARS) - .collect::(); - if suffix.is_empty() { - label - } else { - format!("{label}-{suffix}") + match job_directory_suffix(job_id) { + Some(suffix) => format!("{label}-{suffix}"), + None => label, } } +fn job_directory_suffix(job_id: &str) -> Option { + if job_id.is_empty() { + return None; + } + let digest = Sha256::digest(job_id.as_bytes()); + Some( + format!("{digest:x}") + .chars() + .take(WORKTREE_SUFFIX_CHARS) + .collect(), + ) +} + fn sanitize_label(value: &str) -> Option { let cleaned = value .chars() @@ -2302,26 +2329,186 @@ mod tests { provision_in_store(store, request).expect("second provision"); } + /// Two sessions started from one workspace, with the job ids the controller + /// actually mints. Before the directory suffix digested the id, the second + /// one landed on the first one's checkout and provisioning refused it. + #[test] + fn a_second_session_on_one_workspace_provisions_alongside_the_first() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + let first = "dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f"; + let second = "dispatch-ac8fe8a3-85c7-4091-9ba4-856db2d55c2b"; + + // The controller branches its baseline before bundling, so the managed + // branch is what the target fetches out of the bundle. + let bundle = temp.path().join("base.bundle"); + git(&source, &["branch", &format!("bitfun/dispatch/{first}")]).expect("managed branch"); + git( + &source, + &[ + "bundle", + "create", + path_arg(&bundle).expect("path"), + &format!("bitfun/dispatch/{first}"), + ], + ) + .expect("bundle"); + + let request_for = |job_id: &str| DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: job_id.to_string(), + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + project_label: Some("BitFun".to_string()), + base_commit: base_commit.clone(), + branch: format!("bitfun/dispatch/{job_id}"), + }; + + // The first session has to carry the objects over: nothing is cached yet. + assert!( + provision_in_store(&store, request_for(first)) + .expect("first provision") + .needs_bundle + ); + bundle_begin_in_store( + &store, + DispatchWorkspaceBundleBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: first.to_string(), + sha256: sha256_file(&bundle).expect("digest"), + size: fs::symlink_metadata(&bundle).expect("metadata").len(), + }, + ) + .expect("bundle begin"); + fs::copy( + &bundle, + store + .workspace_upload_dir(first) + .expect("job dir") + .join(INCOMING_BUNDLE_FILE), + ) + .expect("stage bundle"); + bundle_commit_in_store( + &store, + DispatchWorkspaceBundleCommitRequest { + job_id: first.to_string(), + }, + ) + .expect("bundle commit"); + let first_response = provision_in_store(&store, request_for(first)).expect("first publish"); + assert!(first_response.provisioned); + + // The second session shares the repository cache, so it needs no bundle + // — but it must still get a checkout of its own. + let second_response = + provision_in_store(&store, request_for(second)).expect("second session provision"); + assert!(second_response.provisioned, "second session was refused"); + assert!(!second_response.needs_bundle); + + let first_path = first_response.workspace_path.expect("first workspace"); + let second_path = second_response.workspace_path.expect("second workspace"); + assert_ne!( + first_path, second_path, + "both sessions shared one worktree directory" + ); + for path in [&first_path, &second_path] { + assert_eq!( + fs::read(Path::new(path).join("file.txt")).expect("checked out file"), + b"base" + ); + } + // Each checkout is parked on its own managed branch. + for (path, job_id) in [(&first_path, first), (&second_path, second)] { + let branch = git( + Path::new(path), + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .expect("branch"); + assert_eq!(branch.trim(), format!("bitfun/dispatch/{job_id}")); + } + + // And re-provisioning either one is still idempotent. + let repeat = provision_in_store(&store, request_for(first)).expect("first reprovision"); + assert_eq!(repeat.workspace_path.as_deref(), Some(first_path.as_str())); + } + #[test] - fn worktree_directories_are_named_after_the_project_not_the_job() { + fn worktree_directories_lead_with_the_project_label() { + let label = |name: &str| { + name.rsplit_once('-') + .map(|(head, _)| head.to_string()) + .expect("suffixed name") + }; assert_eq!( - worktree_directory_name(Some("BitFun"), None, "dispatch-3d82ff46-bbf9-44c3"), - "BitFun-dispatch" + label(&worktree_directory_name( + Some("BitFun"), + None, + "dispatch-3d82ff46-bbf9-44c3" + )), + "BitFun" ); // No label: the remote's own basename is the next most recognizable name. assert_eq!( - worktree_directory_name(None, Some("git@example.com:acme/app.git"), "abcdef123456"), - "app-abcdef12" + label(&worktree_directory_name( + None, + Some("git@example.com:acme/app.git"), + "abcdef123456" + )), + "app" ); assert_eq!( - worktree_directory_name(None, Some("https://example.com/acme/app/"), "abcdef123456"), - "app-abcdef12" + label(&worktree_directory_name( + None, + Some("https://example.com/acme/app/"), + "abcdef123456" + )), + "app" ); // Neither available: a constant, never an empty or job-shaped path. assert_eq!( - worktree_directory_name(None, None, "abcdef123456"), - "workspace-abcdef12" + label(&worktree_directory_name(None, None, "abcdef123456")), + "workspace" + ); + // An id with no characters to digest still yields a usable directory. + assert_eq!(worktree_directory_name(Some("BitFun"), None, ""), "BitFun"); + } + + /// Every job id is minted as `dispatch-`, so a suffix sliced off the + /// front of the id is the same for all of them. That collapsed every session + /// of a project onto one directory and made the second one fail to provision. + #[test] + fn every_job_of_one_project_gets_its_own_worktree_directory() { + let name = |job_id: &str| worktree_directory_name(Some("BitFun"), None, job_id); + let first = name("dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f"); + let second = name("dispatch-ac8fe8a3-85c7-4091-9ba4-856db2d55c2b"); + + assert_ne!(first, second); + assert!(first.starts_with("BitFun-"), "{first} lost its label"); + assert!(second.starts_with("BitFun-"), "{second} lost its label"); + // Stable across calls: a retry must land on the directory it already has. + assert_eq!(first, name("dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f")); + // Ids that differ only past the slice window still separate. + assert_ne!(name("dispatch-aaaaaaaa-1"), name("dispatch-aaaaaaaa-2")); + } + + #[test] + fn worktree_directory_names_stay_safe_path_components() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let name = worktree_directory_name( + Some("BitFun"), + None, + "dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f", ); + + // `worktree_dir` is the real gate; the digest must clear it unchanged. + let path = store + .worktree_dir("abcdef0123456789", &name) + .expect("worktree path"); + assert!(path.starts_with(store.worktrees_root())); + assert!(path.ends_with(&name)); } #[test] From 11004cedd8ede2e9f4b15ac8648d3579cfa842af Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 16:49:53 +0800 Subject: [PATCH 042/206] fix(web-ui): restore MCP deletion and voice input --- src/apps/desktop/Info.plist | 8 +++ .../voice/useComposerVoiceInput.test.tsx | 32 +++++++++++- .../components/voice/useComposerVoiceInput.ts | 7 +-- .../config/components/McpToolsConfig.test.tsx | 50 +++++++++++++++++++ .../config/components/McpToolsConfig.tsx | 18 ++++++- 5 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/apps/desktop/Info.plist diff --git a/src/apps/desktop/Info.plist b/src/apps/desktop/Info.plist new file mode 100644 index 0000000000..f455508c85 --- /dev/null +++ b/src/apps/desktop/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + BitFun uses the microphone for voice input and local speech transcription. + + diff --git a/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.test.tsx b/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.test.tsx index d6be0b8396..844badd583 100644 --- a/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.test.tsx +++ b/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.test.tsx @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ finishInputSession: vi.fn(), cancelInputSession: vi.fn(async () => undefined), notificationInfo: vi.fn(), + notificationError: vi.fn(), })); vi.mock('@/infrastructure/api', () => ({ @@ -83,7 +84,7 @@ vi.mock('@/shared/notification-system', () => ({ notificationService: { info: mocks.notificationInfo, warning: vi.fn(), - error: vi.fn(), + error: mocks.notificationError, }, })); @@ -137,6 +138,7 @@ describe('useComposerVoiceInput completion modes', () => { mocks.finishInputSession.mockClear(); mocks.cancelInputSession.mockClear(); mocks.notificationInfo.mockClear(); + mocks.notificationError.mockClear(); activateInput = vi.fn(); focusInputSoon = vi.fn(); insertText = vi.fn(() => 'Existing draft Transcribed request'); @@ -221,4 +223,32 @@ describe('useComposerVoiceInput completion modes', () => { expect(submitText).not.toHaveBeenCalled(); expect(mocks.notificationInfo).toHaveBeenCalledOnce(); }); + + it('keeps the idle control actionable when microphone capture is unavailable', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: undefined, + }); + await act(async () => { + root.render( + { controller = next; }} + />, + ); + await Promise.resolve(); + }); + + expect(controller?.disabled).toBe(false); + await act(async () => { + controller?.toggle(); + await Promise.resolve(); + }); + + expect(controller?.phase).toBe('idle'); + expect(mocks.notificationError).toHaveBeenCalledWith('input.voiceInput.unsupported'); + }); }); diff --git a/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.ts b/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.ts index 36075ec6ee..5a16b8365a 100644 --- a/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.ts +++ b/src/web-ui/src/flow_chat/components/voice/useComposerVoiceInput.ts @@ -623,9 +623,10 @@ export function useComposerVoiceInput({ return () => window.removeEventListener('keydown', handleKeyDown, true); }, [cancelRecording, phase]); - const disabled = phase === 'recording' - ? false - : !settings?.enabled || !speechRuntimeSupported || !isMediaCaptureSupported() || phase === 'preparing' || phase === 'transcribing'; + // Keep the idle control clickable when capture is unavailable so the + // start handler can explain the unsupported state instead of silently + // presenting a disabled button. + const disabled = phase === 'preparing' || phase === 'transcribing'; const tooltip = useMemo(() => { if (!settings?.enabled) return t('input.voiceInput.disabled'); if (!speechRuntimeSupported || !isMediaCaptureSupported()) return t('input.voiceInput.unsupported'); diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx index 1608e63d7e..7bd423cf19 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.test.tsx @@ -12,6 +12,8 @@ const loadJsonConfigMock = vi.hoisted(() => vi.fn()); const saveJsonConfigMock = vi.hoisted(() => vi.fn()); const initializeServersMock = vi.hoisted(() => vi.fn()); const startServerMock = vi.hoisted(() => vi.fn()); +const deleteServerMock = vi.hoisted(() => vi.fn()); +const confirmDangerMock = vi.hoisted(() => vi.fn()); const notificationMocks = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn(), @@ -35,6 +37,10 @@ vi.mock('@/infrastructure/runtime', () => ({ vi.mock('@/shared/notification-system', () => ({ useNotification: () => notificationMocks, })); +vi.mock('@/component-library', async () => { + const actual = await vi.importActual('@/component-library'); + return { ...actual, confirmDanger: confirmDangerMock }; +}); vi.mock('../../api/service-api/MCPAPI', () => ({ MCPAPI: { getServers: getServersMock, @@ -42,6 +48,7 @@ vi.mock('../../api/service-api/MCPAPI', () => ({ saveMCPJsonConfig: saveJsonConfigMock, initializeServers: initializeServersMock, startServer: startServerMock, + deleteServer: deleteServerMock, }, })); vi.mock('../../api/service-api/SystemAPI', () => ({ systemAPI: {} })); @@ -67,6 +74,8 @@ describe('McpToolsConfig remote behavior', () => { saveJsonConfigMock.mockReset().mockResolvedValue(undefined); initializeServersMock.mockReset().mockResolvedValue(undefined); startServerMock.mockReset().mockResolvedValue(undefined); + deleteServerMock.mockReset().mockResolvedValue(undefined); + confirmDangerMock.mockReset().mockResolvedValue(true); notificationMocks.success.mockReset(); notificationMocks.warning.mockReset(); notificationMocks.error.mockReset(); @@ -262,4 +271,45 @@ describe('McpToolsConfig remote behavior', () => { expect(notificationMocks.error).not.toHaveBeenCalled(); expect(getServersMock).toHaveBeenCalledTimes(1); }); + + it('deletes a server after confirmation and reloads the list', async () => { + peerState.active = false; + const server = { + id: 'local-test', + name: 'Local test server', + status: 'Stopped', + serverType: 'local', + transport: 'stdio', + enabled: true, + autoStart: false, + commandAvailable: true, + startSupported: true, + }; + getServersMock + .mockResolvedValueOnce([server]) + .mockResolvedValueOnce([]); + + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const deleteButton = container.querySelector('[aria-label="actions.delete"]'); + expect(deleteButton).not.toBeNull(); + await act(async () => { + deleteButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(confirmDangerMock).toHaveBeenCalledWith( + 'actions.delete', + 'messages.deleteConfirm', + { confirmText: 'actions.delete', cancelText: 'actions.cancel' }, + ); + expect(deleteServerMock).toHaveBeenCalledWith({ serverId: 'local-test' }); + expect(getServersMock).toHaveBeenCalledTimes(2); + expect(container.textContent).not.toContain('Local test server'); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx index 9dec5fd63b..cba4236ea5 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx @@ -18,7 +18,14 @@ import { KeyRound, Trash2, } from 'lucide-react'; -import { Button, Textarea, IconButton, Modal, ToolProcessingDots } from '@/component-library'; +import { + Button, + Textarea, + IconButton, + Modal, + ToolProcessingDots, + confirmDanger, +} from '@/component-library'; import { ConfigPageHeader, ConfigPageLayout, @@ -869,7 +876,14 @@ const McpToolsConfig: React.FC = () => { const handleDeleteServer = async (server: MCPServerInfo) => { const capabilityEpoch = currentCapabilityEpoch(); if (capabilityEpoch === null) return; - const confirmed = await window.confirm(tMcp('messages.deleteConfirm')); + const confirmed = await confirmDanger( + tMcp('actions.delete'), + tMcp('messages.deleteConfirm'), + { + confirmText: tMcp('actions.delete'), + cancelText: tMcp('actions.cancel'), + }, + ); if (!confirmed || !capabilityIsCurrent(capabilityEpoch)) return; try { From 634bd59c5ff3b9be071e97d208341ee3796756bc Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 7 Aug 2026 01:26:03 -0700 Subject: [PATCH 043/206] fix(dispatch): bound, anchor and clean up the target's remote fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dispatch target already prefers pulling `base_commit` from the project's Git remote and only asks the controller to ship a bundle when that fails. Three things made the preferred path fragile enough that a large project looked like it had hung. The fetch was unbounded. `git fetch` ran with no deadline and no stall detection, so a dead transport parked the whole dispatch on it with the UI showing one static line. Observed on a target: fifteen minutes inside a single fetch, and the only reason it ended was that someone killed it. Git aborts a stalled HTTP transfer itself given `http.lowSpeedLimit` and `http.lowSpeedTime`, which is the check that actually wants to be tight — it separates "slow but arriving" from "hung", which a total-time budget cannot. A 25-minute backstop covers non-HTTP transports and sits under the controller's own 30-minute ceiling so the target reports the failure. The backstop is deliberately generous: a first fetch of a large project legitimately runs for many minutes, and killing one that is still making progress falls back to shipping the same history over SSH instead — strictly slower than the fetch it replaced. A killed fetch stranded its download. Git writes an incoming pack under a temporary name and only renames it once indexing completes, so an interrupted fetch leaves the whole thing as `tmp_pack_*`. Nothing ever collects those: `git gc` ignores them and they appear in no object count. The same target was holding 207 MiB of them across two dead attempts, and every retry added another copy. They are now swept before each attempt and after a failure, and the fetch runs in its own process group so a timeout stops the transport helper and `index-pack` too, rather than leaving them downloading into a cache nobody is waiting for. The fetch asked for every branch. `+refs/heads/*:refs/remotes/origin/*` pulls all 192 branches of this project when a dispatch only ever checks out one commit. It now asks for that commit, falling back to the old refspec on servers that refuse a bare object id. Measured against the real remote this is a smaller win than it sounds — the branches share almost all of their history — so it is a saving, not the fix. Fetched commits are anchored under `refs/dispatch/bases/`. Asking for a bare object id writes no ref, and the job branch that would hold it goes away with the job, which would leave the cache holding a whole history with nothing pointing at it: invisible to `have_tips`, so the next job bundles everything again, and eligible for `gc` to discard. This also repairs the state that motivated the change — a cache found with 139 MB of objects and zero refs, bundling in full every time. Finally, the target now reports why it fell back. On a cold cache that fallback re-sends the project's entire history, and "the remote refused us" is the difference between a slow dispatch and a broken target; it was previously visible only in the target's own log. This makes the fetch path bounded, self-cleaning and diagnosable. It does not make a first dispatch of a large repository fast — that target pulls from GitHub at about 860 KB/s, so the wait is bandwidth, and what it really needs next is for the transfer to be visible while it happens. --- src/apps/cli/src/dispatch/protocol.rs | 7 + src/apps/cli/src/dispatch/workspace.rs | 381 +++++++++++++++++- .../core/src/service/dispatch/controller.rs | 27 +- 3 files changed, 405 insertions(+), 10 deletions(-) diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index feb61f00c6..18ad8719d5 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -219,6 +219,13 @@ pub(crate) struct DispatchWorkspaceProvisionResponse { /// difference instead of the whole history. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) have_tips: Vec, + /// Why the target could not pull `base_commit` from the project's remote, + /// when it tried and failed. Absent when the remote served the commit, and + /// when there is no remote to try. Bundle delivery costs the whole history + /// on a cold cache, so the reason it was chosen belongs in the record rather + /// than only in the target's log. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) fetch_error: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index 1e1a855ff6..465dccf592 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -15,6 +15,7 @@ use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use base64::Engine as _; @@ -42,6 +43,29 @@ const BUNDLE_RECORD_FILE: &str = "bundle.json"; const SYNC_OPERATION_FILE: &str = "sync-operation.json"; const INCOMING_BUNDLE_FILE: &str = "incoming.bundle"; const RESULT_BUNDLE_FILE: &str = "result.bundle"; +/// Backstop for one fetch from the project's Git remote. +/// +/// The fetch used to be unbounded, so a dead transport parked the whole dispatch +/// on a single `git fetch` forever. This is deliberately not a performance +/// budget: a first fetch of a large project legitimately runs for many minutes, +/// and killing one that is still making progress would fall back to shipping the +/// same history over SSH instead — strictly slower than the fetch it replaced. +/// Stalls are caught by [`FETCH_STALL_SECONDS`]; this only has to fire before +/// the controller's own 30-minute workspace-operation ceiling, so that the +/// target reports the failure rather than the controller timing out on a target +/// that is still waiting. +const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(25 * 60); +/// Bytes per second below which an HTTP fetch counts as stalled. +const FETCH_STALL_BYTES_PER_SECOND: u32 = 1024; +/// How long an HTTP fetch may stay under [`FETCH_STALL_BYTES_PER_SECOND`]. +/// +/// Git aborts the transfer itself once both hold, which is the check that +/// actually wants to be tight: it separates "slow but arriving" from "hung", +/// which a total-time budget cannot tell apart. It only covers HTTP(S) remotes; +/// for SSH remotes the backstop above is the only bound. +const FETCH_STALL_SECONDS: u32 = 60; +/// How often a bounded Git child is checked for exit. +const GIT_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Short per-job suffix that keeps two dispatches of one project apart, matching /// the local managed-worktree convention. const WORKTREE_SUFFIX_CHARS: usize = 8; @@ -297,6 +321,7 @@ fn pending_provision_response( base_commit: request.base_commit.clone(), branch: request.branch.clone(), have_tips: Vec::new(), + fetch_error: None, } } @@ -356,19 +381,28 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: Vec::new(), + fetch_error: None, }); } let repo = ensure_repository(store, &request.repo_key, request.remote_url.as_deref())?; + let mut fetch_error = None; if request.remote_url.is_some() && !commit_exists(&repo, &request.base_commit)? { // A fetch failure is not fatal on its own: the controller can still // deliver the missing objects by bundle, which is also the only path for - // a repository with no remote. - if let Err(error) = fetch_remote(&repo) { + // a repository with no remote. It is reported rather than swallowed, + // because "the target fell back to a full upload" and "the target could + // not reach the remote" look identical from the controller otherwise. + if let Err(error) = fetch_base_commit(&repo, &request.base_commit) { tracing::warn!("Dispatch target could not fetch from the Git remote: {error:#}"); + fetch_error = Some(truncate_utf8(&format!("{error:#}"))); } } if !commit_exists(&repo, &request.base_commit)? { + // A fetch that died partway leaves its pack under a temporary name, so + // the bytes are neither usable nor visible to the tips below. Clearing + // them keeps a repeatedly failing remote from filling the target's disk. + remove_pack_temporaries(&repo); return Ok(DispatchWorkspaceProvisionResponse { pending: false, provisioned: false, @@ -377,6 +411,7 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: repository_tips(&repo)?, + fetch_error, }); } @@ -393,6 +428,7 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: Vec::new(), + fetch_error: None, }) } @@ -1320,18 +1356,102 @@ fn set_origin(repo: &Path, url: &str) -> Result<()> { Ok(()) } -fn fetch_remote(repo: &Path) -> Result<()> { - git( +/// Bring one commit into the repository cache from the project's remote. +/// +/// Asks the server for exactly the commit this job needs. The previous refspec +/// — `+refs/heads/*:refs/remotes/origin/*` — made the first fetch of a project +/// download every branch the server has (192 of them for this repository) when +/// a dispatch only ever checks out `base_commit`. Servers that will not serve a +/// bare object id fall back to the old refspec, so an older or restricted host +/// still works, just as slowly as before. +/// +/// A successful fetch is anchored under `refs/dispatch/bases/`. Asking for a +/// bare object id writes no ref of its own, and the job branch that would hold +/// it goes away with the job, which would leave the cache holding a project's +/// whole history with nothing pointing at it — invisible to `have_tips`, so the +/// next job bundles everything again, and eligible for `gc` to throw away. +fn fetch_base_commit(repo: &Path, base_commit: &str) -> Result<()> { + remove_pack_temporaries(repo); + let targeted = fetch_with_stall_guard(repo, &["--no-tags", "origin", base_commit]); + let Err(error) = targeted else { + anchor_base_commit(repo, base_commit); + return Ok(()); + }; + tracing::debug!( + "Dispatch target could not fetch {base_commit} directly, retrying with every branch: {error:#}" + ); + // The targeted attempt may have died partway through indexing. + remove_pack_temporaries(repo); + fetch_with_stall_guard( repo, &[ - "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", ], ) - .map(|_| ()) +} + +/// Keep a fetched base commit reachable after its job is gone. +/// +/// Best effort on purpose: the fetch already succeeded, and the worktree about +/// to be created keeps the objects alive for this job either way. Failing here +/// would trade a warm cache for a failed dispatch. +fn anchor_base_commit(repo: &Path, base_commit: &str) { + if let Err(error) = git( + repo, + &[ + "update-ref", + &format!("refs/dispatch/bases/{base_commit}"), + base_commit, + ], + ) { + tracing::debug!("Could not anchor dispatch base commit {base_commit}: {error:#}"); + } +} + +/// `git fetch` with the stall guard and the deadline both applied. +fn fetch_with_stall_guard(repo: &Path, fetch_args: &[&str]) -> Result<()> { + let low_speed_limit = format!("http.lowSpeedLimit={FETCH_STALL_BYTES_PER_SECOND}"); + let low_speed_time = format!("http.lowSpeedTime={FETCH_STALL_SECONDS}"); + let mut args = vec![ + "-c", + low_speed_limit.as_str(), + "-c", + low_speed_time.as_str(), + "fetch", + ]; + args.extend_from_slice(fetch_args); + git_within(repo, REMOTE_FETCH_TIMEOUT, &args) +} + +/// Drop `tmp_pack_*` files left behind by a fetch that died while indexing. +/// +/// Git writes the incoming pack under a temporary name and only renames it once +/// the index is complete, so a killed or timed-out fetch strands the whole +/// download. Nothing else ever collects them — `git gc` does not consider them +/// its business, and they are invisible to every object count — so one broken +/// first fetch of a large project parks hundreds of megabytes in the cache +/// permanently, and every retry adds another copy. +/// +/// Callers hold the repository lock, which serializes every Git operation on +/// this clone, so a temporary seen here cannot belong to a live fetch. +fn remove_pack_temporaries(repo: &Path) { + let pack_dir = repo.join("objects").join("pack"); + let Ok(entries) = fs::read_dir(&pack_dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !name.starts_with("tmp_pack_") { + continue; + } + if let Err(error) = fs::remove_file(entry.path()) { + tracing::debug!("Could not remove stale dispatch pack temporary {name}: {error}"); + } + } } fn repository_tips(repo: &Path) -> Result> { @@ -1483,6 +1603,93 @@ fn git(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// Run Git with a deadline, killing it if the deadline passes. +/// +/// Only the remote-facing operations need this. Everything else here works on +/// local objects and finishes in bounded time on its own, whereas a fetch is at +/// the mercy of a network that may never answer — and an unbounded one holds the +/// whole dispatch, and the caller's progress display, hostage. +fn git_within(dir: &Path, timeout: Duration, args: &[&str]) -> Result<()> { + let mut command = git_command(dir); + command + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + // `git fetch` is a supervisor: the transfer really happens in the transport + // helper and `index-pack` that it spawns. Killing only the parent leaves + // those downloading into the cache with nobody waiting for them, which is + // how a cancelled fetch ends up stranding hundreds of megabytes. Giving the + // whole thing its own process group makes it killable as a unit on Unix; + // Windows keeps the old parent-only kill, and the sweep above is what + // reclaims whatever a surviving helper leaves behind. + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + let mut child = command + .spawn() + .with_context(|| format!("run git {}", args.join(" ")))?; + // Drain stderr on its own thread: a child that fills the pipe while nobody + // reads it blocks forever, which would defeat the deadline below. + let mut pipe = child.stderr.take(); + let drain = std::thread::spawn(move || { + let mut captured = String::new(); + if let Some(pipe) = pipe.as_mut() { + let _ = pipe.read_to_string(&mut captured); + } + captured + }); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => {} + Err(error) => { + return Err(error).with_context(|| format!("wait for git {}", args.join(" "))) + } + } + if Instant::now() >= deadline { + kill_process_tree(&mut child); + // Deliberately not joining the drain: any helper that outlived the + // kill still holds the write end, and waiting on it here would put + // the deadline right back where it started. + bail!( + "git {} did not finish within {} seconds", + args.join(" "), + timeout.as_secs() + ); + } + std::thread::sleep(GIT_WAIT_POLL_INTERVAL); + }; + let stderr = drain.join().unwrap_or_default(); + if !status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + truncate_utf8(stderr.trim()) + ); + } + Ok(()) +} + +/// Stop a timed-out Git child and everything it spawned. +fn kill_process_tree(child: &mut std::process::Child) { + #[cfg(unix)] + { + // The child leads its own group (see `process_group` above), so its pid + // doubles as the group id. + let group = child.id() as i32; + if group > 1 { + // SAFETY: `kill` takes a pid and a signal by value and borrows + // nothing; a group that already exited just yields ESRCH. + unsafe { + libc::kill(-group, libc::SIGKILL); + } + } + } + let _ = child.kill(); + let _ = child.wait(); +} + fn git_succeeds(dir: &Path, args: &[&str]) -> Result { let status = git_command(dir) .args(args) @@ -1730,6 +1937,168 @@ mod tests { assert!(!response.provisioned); assert!(response.needs_bundle); assert!(response.workspace_path.is_none()); + // No remote to try, so nothing to explain. + assert_eq!(response.fetch_error, None); + } + + /// A local path is a valid Git remote, so the fast path is testable without + /// a network: the target should pull the commit itself and never ask for a + /// bundle. + #[test] + fn provision_fetches_the_base_commit_from_the_remote_instead_of_bundling() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some(source.to_string_lossy().to_string()), + base_commit: base_commit.clone(), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!(response.provisioned, "the remote had the commit"); + assert!( + !response.needs_bundle, + "no upload should have been asked for" + ); + assert_eq!(response.fetch_error, None); + let workspace = response.workspace_path.expect("workspace path"); + assert_eq!( + fs::read(Path::new(&workspace).join("file.txt")).expect("checked out file"), + b"base" + ); + + // The fetched history must outlive the job that pulled it, or the next + // dispatch of this project pays for the whole download again. + let repo = store + .repo_dir("abcdef0123456789") + .expect("repo dir") + .join("git"); + let anchored = git( + &repo, + &["rev-parse", &format!("refs/dispatch/bases/{base_commit}")], + ) + .expect("the base commit should be anchored"); + assert_eq!(anchored.trim(), base_commit); + assert!( + repository_tips(&repo).expect("tips").contains(&base_commit), + "an anchored base must count as a tip the controller can bundle against" + ); + } + + /// An unreachable remote must degrade to the bundle path *and say why*: on a + /// cold cache that fallback re-sends the project's whole history. + #[test] + fn an_unreachable_remote_reports_why_it_fell_back_to_a_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let missing = temp.path().join("no-such-repository"); + + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some(missing.to_string_lossy().to_string()), + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!(response.needs_bundle); + // The reason has to name the operation and carry Git's own diagnosis; + // "the target fell back to a bundle" on its own is not actionable. + let reason = response.fetch_error.expect("the fallback reason"); + assert!(reason.contains("fetch"), "unhelpful reason: {reason}"); + assert!( + reason.contains("does not appear to be a git repository"), + "the reason dropped Git's own diagnosis: {reason}" + ); + } + + /// A fetch killed while indexing strands its pack under a temporary name. + /// Nothing else collects those, so one broken fetch of a large project used + /// to park its whole download in the cache permanently. + #[test] + fn a_failed_fetch_does_not_leave_its_partial_pack_behind() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let repo_root = store.repo_dir("abcdef0123456789").expect("repo dir"); + // Seed a real cache: a directory that is not a valid bare repository is + // quarantined and rebuilt, which would retire the packs before the + // sweep under test ever sees them. + create_private_dir(&repo_root).expect("repo root"); + git(&repo_root, &["init", "--bare", "--quiet", "git"]).expect("bare repo"); + let pack_dir = repo_root.join("git").join("objects").join("pack"); + fs::create_dir_all(&pack_dir).expect("pack dir"); + fs::write(pack_dir.join("tmp_pack_abandoned"), b"partial download").expect("stranded pack"); + // A real pack must survive: it is the cache this whole path exists for. + fs::write(pack_dir.join("pack-real.pack"), b"kept").expect("real pack"); + + provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some( + temp.path() + .join("no-such-repository") + .to_string_lossy() + .to_string(), + ), + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!( + !pack_dir.join("tmp_pack_abandoned").exists(), + "the stranded pack survived" + ); + assert!( + pack_dir.join("pack-real.pack").exists(), + "a real pack was deleted" + ); + } + + #[test] + fn a_git_command_that_never_finishes_is_killed_at_its_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path().to_path_buf(); + git(&repo, &["init", "--quiet", "--bare"]).expect("init"); + + let started = Instant::now(); + // `--stdin` with a null stdin returns immediately; a long sleep does not. + let error = git_within( + &repo, + Duration::from_millis(300), + &["-c", "alias.stall=!sleep 30", "stall"], + ) + .expect_err("the deadline should have fired"); + + assert!( + started.elapsed() < Duration::from_secs(10), + "it waited too long" + ); + assert!( + format!("{error:#}").contains("did not finish within"), + "unexpected error: {error:#}" + ); } #[test] diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index f7d389bbed..34ec3bce61 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -755,10 +755,19 @@ async fn provision_ssh_workspace( let have_tips = target_have_tips(&response); if base_commit_is_published(&baseline.worktree_path, &baseline.delivery.base_commit).await { // Worth saying out loud: the commit is on the remote, so the target - // asking for it means its clone is stale or its network is down. - log::info!( - "Dispatch target could not reach a published base commit; delivering it by bundle" - ); + // asking for it means its clone is stale or its network is down. Say why + // when the target told us — on a cold cache this fallback re-sends the + // project's whole history over SSH, and "the remote refused us" is the + // difference between a slow dispatch and a misconfigured target. + match target_fetch_error(&response) { + Some(reason) => log::warn!( + "Dispatch target could not fetch a published base commit ({reason}); delivering {} history by bundle instead", + if have_tips.is_empty() { "the entire" } else { "the missing" } + ), + None => log::info!( + "Dispatch target could not reach a published base commit; delivering it by bundle" + ), + } } let bundle = build_base_bundle(store, baseline, &have_tips).await?; let upload = dispatch_ssh::upload_bundle( @@ -782,6 +791,16 @@ async fn provision_ssh_workspace( }) } +/// Why the target fell back to bundle delivery, when it said. +pub(super) fn target_fetch_error(response: &Value) -> Option { + response + .get("fetchError") + .and_then(Value::as_str) + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .map(ToOwned::to_owned) +} + pub(super) fn target_have_tips(response: &Value) -> Vec { response .get("haveTips") From 70eb44984c407519a3386067d1560b5c8b39bc24 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 17:22:16 +0800 Subject: [PATCH 044/206] refactor(harmonyos): split the app shell into MVVM layers AppRoot had grown into a single runtime object that owned routing, remote transport, conversation state and presentation at once, so every feature change reached across all of them. Split it along explicit boundaries: - pages/runtime for the composition root and lifecycle - pages/viewmodel for controllers and view models - pages/policy for pure decision helpers - pages/actions for the typed intent/action surface handed to components - pages/navigation and pages/layout for route and geometry contracts Components now receive typed action objects instead of reaching into view models, which lets Local and Remote share one conversation shell (ConversationRouteSurface on compact, WideConversationHost on wide). Behaviour changes that came out of the split: - Creating a chat from the "chat" option binds the desktop's assistant workspace first. The desktop ignores workspace_path for Claw sessions and always uses its assistant workspace, so the app used to keep showing the code workspace it was on while the session was actually created elsewhere - the new chat never appeared in the list. - Picking a workspace in the create sheet now pairs it with the code agent, so the picker is honoured instead of being silently dropped. - Compact remote conversations open the sidebar over the chat from a menu button, matching local chats, instead of popping back out of the conversation. The system back gesture still leaves the chat and reveals the drawer. Co-Authored-By: Claude Opus 5 --- src/apps/mobile/harmonyos/AGENTS.md | 38 + .../docs/mvvm-architecture-refactor-design.md | 575 ++++ .../wide-conversation-navigation-design.md | 4 +- .../state => model}/FilePreviewTarget.ets | 0 .../entry/src/main/ets/pages/AppRoot.ets | 6 +- .../actions/AppRootPresentationActions.ets | 152 + .../ConversationIntent.ets | 4 +- .../ConversationIntentDispatcher.ets | 33 +- .../components/AppRootOverlaySurfaces.ets | 185 ++ .../pages/components/AppRootPresentation.ets | 1220 +------- .../main/ets/pages/components/AppSidebar.ets | 289 +- .../components/BitFunAccountLoginPage.ets | 18 +- .../pages/components/ChatMessageBubble.ets | 350 +-- .../pages/components/ChatMessageChrome.ets | 109 + .../pages/components/ChatMessageContent.ets | 111 + .../ets/pages/components/ChatStatusBar.ets | 12 +- .../main/ets/pages/components/ComposerBar.ets | 2 +- .../components/ConnectAccountDevicePage.ets | 245 ++ .../ConnectManualPairingOverlay.ets | 107 + .../main/ets/pages/components/ConnectView.ets | 820 +----- .../components/ConversationLoadingState.ets | 58 + .../components/ConversationRouteSurface.ets | 94 + .../components/ConversationSourceSwitcher.ets | 6 +- .../ets/pages/components/ConversationView.ets | 17 +- .../pages/components/ConversationViewHost.ets | 3 +- .../components/ConversationViewSettings.ets | 2 +- .../pages/components/CreateSessionSheet.ets | 26 +- .../pages/components/DefaultAccountAvatar.ets | 4 +- .../pages/components/FileReferenceCard.ets | 22 +- .../pages/components/GeneralChatHeader.ets | 41 +- .../ets/pages/components/MarkdownContent.ets | 8 +- .../components/ModelServiceSettingsPanel.ets | 40 +- .../ets/pages/components/RemoteChatHeader.ets | 10 + .../components/RemoteControlSettingsSheet.ets | 75 +- .../pages/components/RemoteSessionList.ets | 34 +- .../ets/pages/components/SettingsSheet.ets | 34 +- .../ets/pages/components/SidebarGlyphs.ets | 151 + .../components/StreamingMarkdownContent.ets | 15 +- .../src/main/ets/pages/components/Theme.ets | 2 + .../ets/pages/components/ThinkingBlock.ets | 16 +- .../main/ets/pages/components/ToolGlyphs.ets | 40 + .../components/ToolInteractionPanels.ets | 171 ++ .../ets/pages/components/ToolStatusList.ets | 418 +-- .../pages/components/WideConversationHost.ets | 319 ++ .../components/remote/RemoteSurfaceHost.ets | 368 +++ .../ets/pages/layout/WideLayoutGeometry.ets | 35 + .../navigation}/AppRootRouteState.ets | 19 +- .../ets/pages/navigation/AppRouteContract.ets | 4 + .../ConversationLayoutPolicy.ets | 0 .../ConversationModelPresentationPolicy.ets | 0 .../ConversationSessionFilterPolicy.ets | 0 .../FilePreviewPlacementPolicy.ets | 0 .../{state => policy}/SessionActionPolicy.ets | 0 .../main/ets/pages/runtime/AppRootRuntime.ets | 390 +++ .../runtime/AppRootRuntimeComposition.ets | 803 +++++ .../main/ets/pages/state/AppRootRuntime.ets | 2608 ----------------- .../main/ets/pages/state/AppShellState.ets | 10 + .../ets/pages/state/ConversationCoreState.ets | 191 ++ .../ets/pages/state/ConversationViewState.ets | 52 +- .../main/ets/pages/state/FilePreviewState.ets | 2 +- .../ets/pages/state/GeneralChatPageState.ets | 138 +- .../pages/state/RemoteCreateSessionState.ets | 12 +- .../main/ets/pages/state/RemotePageState.ets | 173 +- .../AppShellViewModel.ets | 2 +- .../viewmodel/ConversationController.ets | 1038 +++++++ .../ConversationViewModel.ets | 0 .../pages/viewmodel/FilePreviewController.ets | 92 + .../GeneralChatConversationViewModel.ets | 18 +- .../RemoteActivityViewModel.ets | 34 +- .../RemoteConnectionController.ets} | 4 +- .../RemoteFilePreviewController.ets | 16 +- .../RemoteSessionViewModel.ets | 88 +- .../RemoteWorkspaceViewModel.ets | 24 +- .../pages/viewmodel/SettingsController.ets | 523 ++++ .../main/ets/services/FileTargetResolver.ets | 2 +- .../MessageFileReferenceProjector.ets | 2 +- .../main/resources/base/element/color.json | 8 + .../main/resources/dark/element/color.json | 8 + .../src/test/AppRootLifecycleUnit.test.ets | 42 +- .../test/AppRootRuntimeStartupUnit.test.ets | 19 +- .../entry/src/test/ArchitectureUnit.test.ets | 13 +- .../src/test/ConversationStateUnit.test.ets | 91 + .../entry/src/test/LifecycleUnit.test.ets | 12 + .../src/test/RemoteControllersUnit.test.ets | 35 +- .../test/TransportAndGeneralChatUnit.test.ets | 110 + 85 files changed, 6848 insertions(+), 6024 deletions(-) create mode 100644 src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md rename src/apps/mobile/harmonyos/entry/src/main/ets/{pages/state => model}/FilePreviewTarget.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{components => actions}/ConversationIntent.ets (94%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => actions}/ConversationIntentDispatcher.ets (67%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/{services => pages/navigation}/AppRootRouteState.ets (78%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationLayoutPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationModelPresentationPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/ConversationSessionFilterPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/FilePreviewPlacementPolicy.ets (100%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => policy}/SessionActionPolicy.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets delete mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/AppShellViewModel.ets (98%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/ConversationViewModel.ets (100%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/GeneralChatConversationViewModel.ets (95%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteActivityViewModel.ets (78%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state/RemoteConnectionViewModel.ets => viewmodel/RemoteConnectionController.ets} (99%) rename src/apps/mobile/harmonyos/entry/src/main/ets/{services => pages/viewmodel}/RemoteFilePreviewController.ets (95%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteSessionViewModel.ets (74%) rename src/apps/mobile/harmonyos/entry/src/main/ets/pages/{state => viewmodel}/RemoteWorkspaceViewModel.ets (86%) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index c177c473f1..15e9ccc1a3 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -2,6 +2,44 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. +## MVVM Refactor Boundaries + +This app has one `entry` module, so MVVM is the file-organization boundary for +the module. Keep the official responsibilities explicit: + +- Model/services own data access, persistence, transport, and business logic; + they do not import views or page components. +- Views own presentation and user input; they consume projected state and emit + intents/events rather than calling services directly. +- ViewModels bridge services and views by owning feature state, projecting data, + and handling intents. ViewModels must not import components. + +The following constraints are enforced incrementally by +`pnpm run harmony:architecture` (the runtime behavior checks remain in +`entry/src/test/ArchitectureUnit.test.ets`): + +1. `services/**` must not import `../pages/`. +2. `pages/components/**` must not import `pages/viewmodel/`; imports of + `pages/state/` and `pages/policy/` are allowed for observable state and pure + policies. +3. The page dependency graph must remain acyclic; ViewModels must not depend on + components. +4. Actions and Hooks use typed interfaces with object literals. Do not add + position-dependent callback constructors. +5. New components use `@ComponentV2`; do not add V1 `@Component`, `@State`, + `@Prop`, `@Link`, or `@Watch` declarations. `@BuilderParam` remains supported. +6. General Chat and Remote Chat shared observable fields belong to + `pages/state/ConversationCoreState.ets`. Page-specific state objects compose + that core and must not redeclare the shared `@Trace` fields. + +The current local HarmonyOS verification loop is: + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. diff --git a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md new file mode 100644 index 0000000000..8185fa3cff --- /dev/null +++ b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md @@ -0,0 +1,575 @@ +# HarmonyOS 端 MVVM 架构重构设计 + +Date: 2026-08-06 + +Status: Implementation in progress; S0-S5 and S7 are complete, while S6 component decomposition and the wide-screen visual matrix remain pending + +Scope: `src/apps/mobile/harmonyos/entry/src/main/ets` + +Baseline: commit `6c35485bb`(窄屏 Local/Remote 统一完成后) + +Reference: 华为官方文档 +[MVVM模式(状态管理V2)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/arkts-mvvm-v2-V13)、 +[MVVM模式(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-mvvm)、 +[状态管理(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-v1) + +Related designs: + +- [`adaptive-conversation-ui-redesign.md`](adaptive-conversation-ui-redesign.md) +- [`wide-conversation-navigation-design.md`](wide-conversation-navigation-design.md) +- [`responsive-file-preview-design.md`](responsive-file-preview-design.md) +- [`native-code-preview-implementation-design.md`](native-code-preview-implementation-design.md) + +本文只负责**代码结构**,不改变任何用户可见行为。上述四篇设计继续负责路由合同、折痕几何、文件预览 placement 和会话 UI/UX;本文的每一个阶段都以"这些文档描述的行为在真机上完全不变"为验收前提。发生冲突时,以现有行为文档为准,重构方案让路。 + +--- + +## 0. 结论摘要 + +- **架构基准**:MVVM 是鸿蒙官方文档明确定义的模式,官方把它定位为**单模块内的文件组织方式**;整个应用的模块化官方推荐三层架构(products / features / commons)。本项目 `build-profile.json5` 只有一个 `entry` 模块,正落在 MVVM 覆盖的范围内——**MVVM 是本次重构正确且足够的框架,三层架构不在本次范围**。 +- **好消息**:ViewModel 层已经是干净的。7 个 `*ViewModel` 共 1379 行,**没有任何一个 import `components/`**。MVVM 里最难守住的一条,这里已经守住了。 +- **重构结果**:`services/` → `pages/`、`components` → `viewmodel`、`viewmodel` → `components` 当前均为零;运行时组合根已拆为 `AppRootRuntime` 与 `AppRootRuntimeComposition`,特性行为由四个 Controller 持有。 +- **一条被更正的判断**:初版诊断把"10 个 `components/*` import `../state/`"列为分层违规,**这是错的**,详见 §3.4。 +- **状态管理范式统一到 V2**:基线有 15 个 V1 struct(5114 行)与 19 个 V2 struct 混用;S5 已将这 15 个组件全部迁移到 V2。V2 是官方对新项目的推荐范式,也是官方 MVVM 示例的形式,详见 §2.8 与 §5 的 S5 阶段。 +- **实施方式**:S0–S7 八个阶段,每个阶段独立可发布、可回滚,前三个阶段零行为变更。 + +--- + +## 1. 架构基准 + +### 1.1 官方 MVVM 的三条职责界定 + +引自华为官方文档: + +- **model** —— 负责数据的获取和存储以及业务逻辑,**不与 view 关联**; +- **view** —— 负责界面展现和用户输入,**不与 model 关联**; +- **viewmodel** —— 作为连接二者的桥梁,负责将 model 数据转为 view 数据并管理界面状态。 + +官方 V2 示例的绑定形式是 `@ComponentV2` + `@Local` 持有 ViewModel 实例。 + +本文后续所有"违规"判定,都直接引用上面三句,不引入本文自创的架构偏好。 + +### 1.2 范围界定:MVVM vs 三层架构 + +官方对二者的分工是明确的: + +> MVVM 的目录组织方式一般适用于**单个模块内**的文件组织;为了更好地适配复杂应用开发,建议采用**三层架构**对**整个应用**功能进行模块化。 + +| 层级 | 编译产物 | 依赖约束 | +| --- | --- | --- | +| products(产品定制层) | Entry HAP | 可依赖 features / commons,禁止横向调用 | +| features(基础特性层) | HAR / HSP | 可依赖 commons,避免反向依赖 products | +| commons(公共能力层) | HAR / HSP | 不可依赖上层 | + +**本项目现状**:`build-profile.json5` 的 `modules` 只有 `entry` 一项,`compatibleSdkVersion 6.0.1(21)` / `targetSdkVersion 6.1.1(24)`。单模块 = MVVM 的适用范围。 + +**三层架构的引入时机**(记录,本次不做):当需要为不同设备形态提供差异化入口(折叠屏 / 平板 / 车机各自的 Entry HAP),或 `services/` 需要被鸿蒙端之外复用时,才是把 `services/` 抽成 commons HAR、把会话/Remote 抽成 features HSP 的时机。在只有一个 entry 的现在做这件事,只增加构建复杂度,不带来收益。 + +### 1.3 ArkTS/ArkUI 层面必须遵守的既有教训 + +这些是本模块已经付出过代价的约束,重构中任何一步都不得违反: + +1. **`@Builder` 的值参数不具备响应式**。只有按引用传入的单个对象参数才会驱动重渲染;builder 内部读 `this.` 才是可靠的。拆分 builder 时,凡是原先从父 builder 传入的宽度、来源等标量,一律改为在子 builder 内部读状态。 +2. **`NavPathStack` 不可观测**。任何存活于 `Navigation` 之外的界面(抽屉是典型)都不能靠它驱动刷新,必须消费 `AppShellState.activeRoute` 这个 `@Trace` 镜像。该镜像由 `AppShellViewModel.syncActiveRoute()` 统一维护,**新增导航路径必须经由 `AppShellViewModel`**。 +3. **V1 / V2 混用现状**:`@Component/@State/@Prop` 与 `@ComponentV2/@Local/@Param/@Event` 并存。本次**全量迁移到 V2**,范式统一后 §1.3.1 和 §1.3.2 两条约束的心智负担也随之下降(V2 的观测边界比 V1 明确)。分布数据见 §2.8,实施见 §5 的 S5 阶段。 + +--- + +## 2. 现状测量 + +以下全部为实测值,非估算。 + +### 2.1 规模基线 + +| 目录 | 文件数 | 行数 | +| --- | --- | --- | +| `pages/components` | 39 | 14518 | +| `services`(含 `general-chat` 21 / 3296) | 51 | 7492 | +| `pages/state` | 21 | 5645 | +| `i18n` | — | 581 | +| `model` | — | 465 | +| `pages/navigation` | — | 110 | +| 测试 `entry/src/test` | 8 | 6612 | + +### 2.2 `pages/state/` 的真实构成(一个目录装了三层) + +| 类别 | 文件 | 行数 | +| --- | --- | --- | +| ViewModel | `AppShellViewModel` 98、`ConversationViewModel` 22、`GeneralChatConversationViewModel` 336、`RemoteActivityViewModel` 163、`RemoteConnectionViewModel` 353、`RemoteSessionViewModel` 236、`RemoteWorkspaceViewModel` 171 | 1379 | +| State(`@ObservedV2` 绑定对象) | `AppShellState` 58、`ConversationViewState` 120、`FilePreviewState` 107、`GeneralChatPageState` 200、`RemoteCreateSessionState` 113、`RemotePageState` 373 | 971 | +| Policy(纯逻辑,零 `@Trace`) | `ConversationLayoutPolicy` 156、`FilePreviewPlacementPolicy` 185、`ConversationModelPresentationPolicy` 82、`ConversationSessionFilterPolicy` 51、`SessionActionPolicy` 31 | 505 | +| God Facade | `AppRootRuntime` | 2608 | +| 其他 | `ConversationIntentDispatcher`、`FilePreviewTarget` 等 | 约 182 | + +### 2.3 两个引力井的内部构成 + +**`AppRootPresentation.ets`(1449 行)**——可分离,各段落关注点互不相干: + +| 段落 | 行数 | 性质 | +| --- | --- | --- | +| 7 个 action DTO 定义(L62–270) | 209 | 属于 model 定义,不该在 view 文件里 | +| Remote UI builders | 305 | 一个独立特性面 | +| Remote 辅助方法 | 143 | 同上 | +| 宽屏几何计算 | 179 | 纯计算,可脱离 UI,当前零单测覆盖 | +| 宽屏 builders | 275 | 一个独立布局面 | + +共 25 个 `@Builder`、约 50 个私有方法、21 个 `@Local`(其中 13 个属于宽屏几何、8 个属于 Remote 过滤/元数据)。两组 `@Local` 混在同一 struct 内,意味着改宽屏分栏宽度会连带触发 Remote 过滤区重算。 + +**`AppRootRuntime.ets`(2608 行)**——性质不同,是"所有特性的门面开在同一个类上": + +- 183 个方法级条目,约 101 个 public,其中 **45 个是一行转发**; +- 75 个 import; +- 字段初始化块从 L140 延伸到 L761(621 行); +- 单个方法最长 `selectCloudAccountDevice` 91 行。 + +### 2.4 接线代码 + +12 个 `*Hooks` / `*Actions` 类:定义 412 行,在 `AppRootRuntime` 中的构造点 235 行,合计约 **650 行纯接线**。 + +其中 7 个定义在 `AppRootPresentation.ets` 内(L62–270,209 行)。构造点规模:`AppRootPresentationActions` 96 行、`RemoteSessionViewModelHooks` 46 行、`ConversationIntentDispatcherHooks` 39 行、两个 Hooks 各 23 行、一个 8 行。 + +全部为**位置参数构造**: + +```ts +new AppRootPresentationActions(a, b, c, d, /* …共 96 行实参 */) +``` + +代价不只是行数——新增一个回调要同步改三处(DTO 定义、构造点、消费点),且位置参数在 ArkTS 里没有编译期的名字保护:两个相邻的同签名回调若被调换顺序,编译通过、运行时行为错乱。这是本模块唯一一类"改对了也无法在编译期确认"的修改。 + +### 2.5 会话状态的重复 + +`GeneralChatPageState`(200)与 `RemotePageState`(373)有**约 15 个字段同名同义**。为了让上层统一消费,又长出两层扇入扇出: + +- `services/AppRootRouteState.ets`(88 行)——存在的唯一理由是在两者之间搬数据; +- `ConversationViewState.project(route, remote, general, …)`——再做一遍同样的归约; +- 分散各处的 `compact` 布尔与 `if (source === General)` 分支。 + +后果:每新增一项会话能力(附件、引用、重发……),要在两个 State 各写一次,再在两个投影层各接一次。 + +### 2.6 组件层 + +内联 glyph / icon builder 共 **538 行**,分布在 10 个文件:`AppSidebar` 179、`ConnectView` 99、`ToolStatusList` 91、`ConversationView` 61、`ChatMessageBubble` 35、`SessionActionSurface` 19、`CreateSessionSheet` 18,`ComposerBar` / `RemoteCreateSessionView` / `ChatTimeline` 各 12。 + +第二梯队大结构体:`ToolStatusList` 1442 行 / 16 builders、`ConnectView` 1344 / 24、`ChatMessageBubble` 1246 / 18、`AppSidebar` 908 / 29。 + +### 2.7 现有安全网 + +`entry/src/test/` 共 6612 行 hypium 用例: + +| 文件 | 行数 | +| --- | --- | +| `RemoteControllersUnit` | 2177 | +| `TransportAndGeneralChatUnit` | 1255 | +| `LocalTestFixtures` | 1078 | +| `ConversationStateUnit` | 1057 | +| `LifecycleUnit` | 748 | +| `AppRootLifecycleUnit` | 129 | +| `ArchitectureUnit` | 95 | +| `AppRootRuntimeStartupUnit` | 51 | + +本地运行方式(已实测通过,BUILD SUCCESSFUL 11s,报告落在 `entry/.test/default/outputs/test/reports/`): + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + +注:现有 `ArchitectureUnit`(95 行)测的是**行为**(生成号失效、时间线归约、路由栈不变量),不是分层。分层目前无任何自动化约束。 + +### 2.8 V1 / V2 范式分布(重构前基线) + +**结构体**:V1(`@Component`)15 个,共 **5114 行**;V2(`@ComponentV2`)19 个。 + +**装饰器用量**: + +| V1 | 次数 | V2 | 次数 | +| --- | --- | --- | --- | +| `@Prop` | 79 | `@Param` | 155 | +| `@State` | 53 | `@Local` | 62 | +| `@BuilderParam` | 7 | `@Event` | 104 | +| `@Watch` | 4 | `@Trace` | 99 | +| `@Link` | 2 | `@ObservedV2` | 5 | +| `@Observed` / `@ObjectLink` / `@Provide` / `@Consume` / `@StorageLink` / `@StorageProp` | 0 | `@Monitor` | 4 | + +(`@BuilderParam` 在 V1 与 V2 中均受支持,不属于迁移面。) + +**V1 文件清单与迁移面**: + +| 文件 | 行数 | `@State` | `@Prop` | `@Link` | `@Watch` | +| --- | --- | --- | --- | --- | --- | +| `ConnectView.ets` | 1344 | 12 | 16 | — | — | +| `AppSidebar.ets` | 908 | 7 | 11 | — | — | +| `RemoteControlSettingsSheet.ets` | 872 | 13 | 12 | — | 1 | +| `ModelServiceSettingsPanel.ets` | 662 | 10 | 5 | — | — | +| `SettingsSheet.ets` | 297 | 4 | 8 | — | — | +| `CreateSessionSheet.ets` | 226 | — | 4 | 2 | — | +| `MarkdownContent.ets` | 199 | — | 1 | — | — | +| `BitFunAccountLoginPage.ets` | 146 | 5 | — | — | — | +| `StreamingMarkdownContent.ets` | 142 | 1 | 3 | — | 3 | +| `FileReferenceCard.ets` | 85 | — | 8 | — | — | +| `ThinkingBlock.ets` | 67 | 1 | 5 | — | — | +| `ChatStatusBar.ets` | 60 | — | 4 | — | — | +| `AppRoot.ets` | 48 | — | — | — | — | +| `ConversationSourceSwitcher.ets` | 40 | — | 1 | — | — | +| `DefaultAccountAvatar.ets` | 18 | — | 1 | — | — | + +**集中度**:前 4 个文件占 3786 行(V1 总量的 74%)、86 个 V1 状态装饰器(占 65%)。其中 `ConnectView` 与 `AppSidebar` 同时也是 S6 拆分的目标,可就近编排。 + +**当前是否已有跨范式错误用法**:已逐文件核查,**没有**。5 个 `@ObservedV2` 类(`AppShellState`、`RemotePageState`、`GeneralChatPageState`、`RemoteCreateSessionState`、`FilePreviewState`)**没有任何一处被 V1 的 `@State` / `@Prop` / `@Link` 持有**——官方不支持 `@ObservedV2` 对象走 V1 观测机制,这条目前没有被踩到。 + +所以全量迁移 V2 **不是在修复既有 bug,而是在消除一类风险**:只要 V1 struct 还在,任何一次后续改动都可能把某个 `@ObservedV2` 对象传进 V1 的 `@Prop`,届时得到的是"编译通过、界面不刷新"——与本模块此前踩过的抽屉不刷新(§1.3.2)完全同型、且同样难以定位的故障。 + +--- + +## 3. 诊断 + +### 3.1 符合官方定义的部分 + +- **ViewModel 层是干净的**:7 个 VM 共 1379 行,零 import `components/`。ViewModel 完全不知道 UI 存在。 +- **Policy 层是纯的**:5 个 Policy 共 505 行,零 `@Trace` / 零 `@ObservedV2`,可直接单测。 +- **已有一处标准 MVVM 三件套**:`ConversationViewState`(投影,120)→ `ConversationViewHost`(哑视图,91)→ `ConversationIntent` / `ConversationIntentDispatcher`(意图,120)。**这是本次重构要推广的形状,不需要发明新范式。** + +### 3.2 硬违规(按 §1.1 官方定义判定) + +| 官方职责 | 违规 | 证据 | +| --- | --- | --- | +| model **不与 view 关联** | `services/` → `pages/` 反向依赖 | `AppRootRouteState`、`FileTargetResolver`、`RemoteFilePreviewController`、`MessageFileReferenceProjector` 共 4 个文件 import `../pages/` | +| view **不与 model 关联** | view 文件持有 model 定义,导致真实模块环 | `AppRootPresentation.ets` L62–270 定义 209 行 action DTO → `AppRootRuntime` 反向 import `AppRootPresentation` | +| viewmodel 是**桥梁** | `AppRootRuntime` 不是桥梁,是 God Facade | 2608 行 / 101 public / 45 一行转发 / 621 行字段初始化块;所有 view 绑到同一个巨型对象,而非各自绑到所属特性的 VM | + +### 3.3 结构性问题(不算违规,但是主要成本来源) + +1. **接线子系统化**(§2.4,约 650 行)——位置参数构造带来无编译期保护的修改风险。 +2. **会话状态双份实现**(§2.5)——每项能力写四遍。 +3. **目录命名说谎**(§2.2)——`pages/state/` 一个目录装了 ViewModel / State / Policy / God Facade 四类东西,"这个文件属于哪一层"无法从路径判断,也导致分层断言写不出来。 +4. **组件层关注点混合**(§2.6)——538 行内联图标 + 四个千行级结构体。 + +### 3.4 更正:一条被推翻的初版判断 + +初版诊断把 **"10 个 `components/*` import `../state/`" 列为分层被打穿。这个判断是错的**,此处保留记录以免后续重复犯错。 + +逐文件查证结果——这 10 个文件 import 的**全部是 State 类与 Policy 类,没有一个 import `*ViewModel`**: + +``` +AppShell.ets → AppShellState +AppSidebar.ets → SessionActionPolicy +ConversationViewHost.ets → ConversationViewState +ComposerBar.ets → ConversationModelPresentationPolicy +FilePreviewSurface.ets → FilePreviewState +ConversationIntent.ets → FilePreviewTarget +ConversationViewSettings → ConversationSessionFilterPolicy +RemoteSessionList.ets → SessionActionPolicy, ConversationSessionFilterPolicy +RemoteCreateSessionView → RemoteCreateSessionState +AppRootPresentation.ets → AppShellState 等 6 个 State/Policy +``` + +View 持有 `@ObservedV2` 状态对象**正是 ArkUI V2 官方推荐的绑定方式**,不是违规。真正的问题是 §3.3 第 3 条:目录名叫 `state`,内容却是四层,让合规的 import 看起来像违规。 + +**因此 S6 的目标已相应修正**:从"切断 `components → state` 的 import"改为"消除内联图标与多关注点混合"。 + +--- + +## 4. 目标结构 + +依赖单向向下,`pages/state/` 按真实层次拆开: + +``` +pages/ + ├─ AppRoot.ets @Entry,组合根 + ├─ actions/ 所有 Actions/Hooks 接口定义(从 view 文件搬出,环即断) + ├─ viewmodel/ 7 个 *ViewModel + 按特性拆出的 Controller + ├─ state/ 纯 @ObservedV2 绑定对象 + ├─ policy/ 纯逻辑,无装饰器,全部可单测 + ├─ layout/ WideLayoutGeometry 等纯几何计算 + ├─ navigation/ AppRouteContract(叶子) + └─ components/ 哑视图 + Glyphs 图标库 +services/ model 层:领域与传输,禁止 import ../pages +model/ i18n/ 叶子 +``` + +**五条硬约束**(S0 写入 `AGENTS.md` 并以"已知清单"模式开始由 `ArchitectureUnit` 拦截新增违规;第 5 条在 S5 完成后转为强制,第 1–3 条在 S7 完成后转为强制): + +1. `services/**` 不得 import `../pages/`; +2. `pages/components/**` 不得 import `pages/viewmodel/`(import `state/` `policy/` 合法); +3. 不存在任何模块环,`viewmodel → components` 方向禁止; +4. Actions/Hooks 一律 `interface` + 对象字面量,禁止位置参数构造; +5. **组件一律 `@ComponentV2`**,禁止新增 `@Component` / `@State` / `@Prop` / `@Link` / `@Watch`(`@BuilderParam` 不在此列,V2 亦支持)。 + +**每个特性面的标准形状**(推广 §3.1 已有的三件套): + +``` +XxxViewState 投影:把 model 数据转成 view 数据 +XxxHost 哑视图:只接 @Param 和回调 +XxxIntent 意图:view 向上表达"用户想做什么" +XxxViewModel 桥梁:持有 state、消费 services、处理 intent +``` + +--- + +## 5. 分阶段方案 + +按"风险调整后收益"排序。S0–S2 零行为变更。每阶段独立可发布、可回滚。 + +### S0 · 立规则与护栏(0.5 天,零行为变更) + +**做什么** + +1. 把 §1.1 官方三条职责、§1.2 范围界定、§4 五条硬约束写入 `src/apps/mobile/harmonyos/AGENTS.md`; +2. 把 §2.7 的本地测试命令补进 `AGENTS.md`(目前未文档化); +3. 扩展 `ArchitectureUnit.test.ets`,新增两组源文件扫描断言,均采用**"已知清单"模式**——断言"当前违规集合 == 登记清单",从此新增违规立即失败,存量按阶段递减: + - 分层断言:登记当前 5 处(`services → pages` 4 处 + `runtime → presentation` 1 处),S7 清零; + - **范式断言**:登记当前 15 个 V1 文件(§2.8 清单),S5 清零。这一条从 S0 当天起就阻止新增 V1 组件,避免迁移期间边迁边长。 + +**为什么先做**:规则来自官方文档,不需要团队内部论证;两份清单让后续每阶段的进度可测,且"只减不增"是机器保证的。 + +**风险**:无。不触碰产物代码。 + +--- + +### S1 · 从 view 中取出 model 定义,断环 + 拆分引力井(1–2 天,零行为变更) + +**做什么** + +1. **7 个 action DTO(L62–270,209 行)→ `pages/actions/`**。单独这一步就消掉硬违规 ② 与循环依赖,建议独立成第一个 commit。 +2. Remote builders + helpers(448 行)→ `pages/components/remote/RemoteSurfaceHost.ets`,带走 8 个 Remote `@Local`。 +3. 宽屏几何(179 行)→ `pages/layout/WideLayoutGeometry.ets`,纯函数,**顺带补单测**(当前零覆盖)。宽屏 builders 带走 13 个几何 `@Local`。 +4. `pages/state/` 按 §4 拆成 `viewmodel/` `state/` `policy/`——纯改目录与 import 路径,零逻辑改动,但让 S0 的断言写得出来。 + +目标:`AppRootPresentation.ets` 从 1449 行收敛到约 300 行的装配壳。 + +**实际结果(2026-08-07)**:7 组 action DTO 已迁入 `pages/actions/`;Remote、 +窄屏路由、宽屏会话与根级 overlay 分别由 `RemoteSurfaceHost`、 +`ConversationRouteSurface`、`WideConversationHost`、`AppRootOverlaySurfaces` +持有。宽屏几何已迁入 `pages/layout/WideLayoutGeometry.ets`,并由 +`ArchitectureUnit` 覆盖关键几何约束。`AppRootPresentation.ets` 从基线 1449 行 +收敛到 406 行,保留响应式测量、`Navigation`、compact preview overlay、Remote +settings sheet 与顶层装配。架构门禁要求该文件不超过 500 行,并要求上述拆分文件 +持续存在。 + +HAP、LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。真机 smoke 曾发现 +`@BuilderParam` slot 内直接构造 V2 组件会触发 `class constructor cannot called without +'new'`;现已改为由 `@Builder` 方法承接 slot,并复验进程在完整往返中持续存活。 +当前两个 target 分别为 1080 × 2444 真机和 466 × 466 模拟器,均不能提供宽屏三栏 +验收条件,因此 S1 的宽屏视觉复验仍记为待办。 + +**风险点(本阶段唯一)**:`@Builder` 值参数不响应式(§1.3.1)。拆分后凡是原先由父 builder 传入的标量,必须改为子 builder 内读状态——`wideMasterPaneCurrentWidth()` 就是这个坑的既有修复案例。 + +**验证**:完整验证回路 + **必须真机复验宽屏三栏与窄屏抽屉来源切换**。 + +--- + +### S2 · 消灭位置参数接线(2–3 天,零行为变更) + +**做什么**:12 个 `*Hooks` / `*Actions` 由 `class` + 位置构造改为 `interface` + 对象字面量。 + +```ts +// before —— 96 行实参,顺序错了编译期无感 +new AppRootPresentationActions(onA, onB, onC, /* … */) + +// after —— 字段名保护,新增回调只改两处 +const actions: AppRootPresentationActions = { + onA: () => { /* … */ }, + onB: () => { /* … */ }, + onC: () => { /* … */ } +}; +``` + +约 650 行接线降至约 250 行。可按 12 个类逐个 commit,每个独立可回滚。 + +**风险**:低。ArkTS 对象字面量要求有明确声明类型,`interface` 满足;改造过程中若某个 Hooks 含方法实现而非纯回调字段,保留为 class 但改为具名参数对象构造。 + +--- + +### S3 · 统一会话状态(3–5 天,**有行为风险**) + +**做什么** + +1. 抽出承载 §2.5 那 15 个共享字段的公共载体;`GeneralChatPageState` / `RemotePageState` 只保留各自特有字段; +2. 删除 `services/AppRootRouteState.ets`(88 行)——同时消掉硬违规 ① 的四分之一; +3. 收敛 `ConversationViewState.project` 的双源分支。 + +**前置 spike(0.5 天,必做)**:验证 ArkUI V2 的 `@Trace` 能否穿透 `@ObservedV2` 基类继承——本模块目前没有先例,不能假设。 + +- 若可以 → 用继承(`ConversationSessionState` 基类)。 +- 若不行 → **退化为组合**:两个 State 各持有一个 `ConversationCore` 字段,投影层只读 core。效果等价,只是访问路径多一层。 + +**Spike 结论(2026-08-06)**:采用组合方案。当前工程没有可证明 `@Trace` +跨 `@ObservedV2` 基类继承订阅关系的运行时先例,HAP 编译和 LocalTest 只能证明语法与 +状态行为,不能证明 UI 订阅穿透。`GeneralChatPageState` 与 `RemotePageState` 因此各自组合 +独立的 `ConversationCoreState`,组件和 `ConversationViewState` 直接读取 core。已通过窄屏 +真机 Local → Remote → Local 往返验证;宽屏真机仍需在折叠设备展开后复验。 + +**风险**:本方案中最高。但安全网充足——`ConversationStateUnit`(1057)+ `RemoteControllersUnit`(2177)直接覆盖这块。 + +**验证**:完整回路 + 真机走通四条路径:本地新建/继续会话、Remote 新建/继续会话、窄屏抽屉来源切换、宽屏来源切换。 + +--- + +### S4 · 拆解 God Facade(4–6 天,分批) + +**做什么**:按 S3 建立的特性边界,把 `AppRootRuntime` 切成 `ConversationController` / `RemoteConnectionController` / `SettingsController` / `FilePreviewController`,`AppRootRuntime` 退化为持有它们的组合根。 + +**实施结果(2026-08-07,已完成)**:已落地 `FilePreviewController`、 +`SettingsController`,并建立 `ConversationController` 的首批跨表面 composer/voice 状态边界; +对应旧方法已从 `AppRootRuntime` 删除,静态门禁禁止回流。现有连接实现也已从 +`RemoteConnectionViewModel` 更名为 `RemoteConnectionController`,根运行时的 21 个状态 getter +和 11 个连接状态转发已删除;路由、workspace/session 列表、polling/heartbeat 的 28 个 +owner 转发也已改为直接绑定。云账号凭据、持久化、云模型目录、权限设置与账号设备切换 +闭环也已迁入 `SettingsController`,包括原 91 行的 `selectCloudAccountDevice`。 +远程会话的发送、停止/重试、工具动作、时间线投影与 polling cursor 运行态已迁入 +`ConversationController`;Remote 新建会话的设备/workspace/模型选择、提交与路由流程也由其 +统一持有。本地会话的打开/新建/发送、草稿、归档与时间线投影同样已收口到该 owner。 +根运行时由 2608 行降至 372 行;纯依赖实例化和回调接线迁入 +`AppRootRuntimeComposition`,其抽象端口仍由根运行时实现,避免装配层反向拥有页面生命周期行为。 +HAP、完整 LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。尚未完成 +宽屏复验,仍等待可用的展开设备。 + +顺序(每步独立 commit): + +1. 清理 45 个一行转发——调用点直接指向真正的 owner; +2. 拆 621 行字段初始化块(L140–761)为各 Controller 的构造; +3. 处理 `selectCloudAccountDevice`(91 行)等长方法; +4. 按官方 V2 形状收口:view 用 `@ComponentV2` + `@Local` 持有**所属特性的** ViewModel,而非同一个巨型对象。 + +**与 S5 的次序说明**:本阶段涉及的装配层(`AppRootPresentation` 及其拆出的 host)已经是 V2,`AppRoot.ets` 虽是 V1 但无任何状态装饰器,因此第 4 步不需要等 S5。S5 排在其后,是因为它的主体(`ConnectView`、`AppSidebar` 等叶子组件)与 Controller 拆分互不相干,放在结构稳定之后迁移,可以避免同一文件被两种性质的改动连续翻动。 + +目标:`AppRootRuntime` < 500 行。消除硬违规 ③。 + +**风险**:中。生命周期是重点——`aboutToAppear` / `onPageShow` / `onPageHide` / `aboutToDisappear` / `handleRootBack` 的调用顺序与轮询启停必须逐一保持。`LifecycleUnit`(748)+ `AppRootLifecycleUnit`(129)+ `AppRootRuntimeStartupUnit`(51)覆盖此处。 + +--- + +### S5 · V1 全量迁移到 V2(4–5 天,**逐文件有行为风险,已完成 2026-08-07**) + +**做什么**:把 §2.8 清单里的 15 个 V1 struct 全部迁到 `@ComponentV2`,之后 `pages/` 下不再存在 V1 装饰器。 + +**为什么值得单列一个阶段**(而不是像初版那样"顺手统一"): + +1. **官方推荐**。V2 是官方对新项目的推荐范式,官方 MVVM 示例也是 `@ComponentV2` + `@Local` 持有 ViewModel 实例的形式。范式统一后 §4 的目标结构与官方文档一一对应,不需要读代码的人在两套心智模型间切换。 +2. **消除一类难定位故障**。§2.8 已核查:目前**没有**任何 `@ObservedV2` 对象被 V1 装饰器持有。但只要 V1 struct 还在,后续任何一次改动都可能把状态对象传进 `@Prop`,得到"编译通过、界面不刷新"——与抽屉不刷新(§1.3.2)同型的故障,本模块已经为这类问题付出过一次排查成本。 +3. **观测边界更明确**。V2 的 `@Trace` 深度观测与 `@Monitor` 的新旧值回调,比 V1 的 `@Observed` / `@ObjectLink` 嵌套观测更容易推理,也更容易在 review 中判断对错。 + +**迁移映射表**(逐条替换,不是全局改名): + +| V1 | V2 | 语义差异——**必须逐字段确认,这是本阶段的主要风险**| +| --- | --- | --- | +| `@Component` | `@ComponentV2` | — | +| `@State`(53) | `@Local` | 基本等价,子组件自有状态 | +| `@Prop`(79) | `@Param` | **不等价**。V1 `@Prop` 是**深拷贝**,子组件可以本地改写;V2 `@Param` 是**按引用只读**,子组件不可赋值。凡是子组件确实在本地改写该字段的,需迁为 `@Param @Once`(仅初始同步、之后子组件自持)或 `@Local` + 显式初始化 | +| `@Link`(2) | `@Param` + `@Event` | **不等价**。V2 取消了双向绑定,须拆成"向下传值 + 向上回调"。仅 `CreateSessionSheet.ets` 的 `sessionTitle` / `instruction` 两处 | +| `@Watch`(4) | `@Monitor` | 回调签名不同,`@Monitor` 提供新旧值;`RemoteControlSettingsSheet` 1 处、`StreamingMarkdownContent` 3 处 | +| `@BuilderParam`(7) | 不变 | V2 同样支持,不属于迁移面 | + +**顺序**(每个文件独立 commit,从小到大以便先摸清坑): + +1. 先迁 5 个小文件(`DefaultAccountAvatar` 18、`ConversationSourceSwitcher` 40、`AppRoot` 48、`ChatStatusBar` 60、`ThinkingBlock` 67)——`AppRoot` 无任何状态装饰器,是纯粹的 `@Component` → `@ComponentV2` 改名,可作为第一个 commit 验证工具链; +2. 迁 `@Link` / `@Watch` 三个特殊文件(`CreateSessionSheet` 226、`StreamingMarkdownContent` 142、`RemoteControlSettingsSheet` 872)——语义变化集中在这里,单独处理便于 review; +3. 迁剩余中等文件(`FileReferenceCard` 85、`BitFunAccountLoginPage` 146、`MarkdownContent` 199、`SettingsSheet` 297、`ModelServiceSettingsPanel` 662); +4. 最后迁 `AppSidebar`(908)与 `ConnectView`(1344)——这两个占 V1 总量 44%,且是 S6 的拆分目标,**先迁后拆**:若先拆再迁,会在拆分过程中制造 V1/V2 交界,把两类风险叠在同一个 commit 里。 + +**风险**:中。集中在 `@Prop` → `@Param` 的 79 处——**不能批量替换**,每一处都要确认子组件是否本地改写。`StreamingMarkdownContent` 尤其要小心:它的 3 个 `@Prop` 全部带 `@Watch`,流式 Markdown 的增量渲染依赖这套回调时序。 + +**验证**:完整回路,且**每个 commit 都要真机验证该组件所在界面**。重点回归:连接流程(`ConnectView`)、侧栏与会话列表(`AppSidebar`)、Remote 控制设置(`RemoteControlSettingsSheet`)、流式回复渲染(`StreamingMarkdownContent`)、新建会话(`CreateSessionSheet`)。 + +**完成标志**:`ArchitectureUnit` 的 V1 已知清单清空,范式断言由"等于清单"翻为"必须为空";此后新增 V1 组件在 CI 直接失败。 + +**实际结果**:15 个 V1 页面组件全部迁移。逐字段审计结论是:只读父输入迁为 +`@Param`;需要用户编辑的值由子组件 `@Local` draft 持有,并通过显式事件上送; +`CreateSessionSheet` 的两个 `@Link` 拆为 `@Param` + `@Event`; +`StreamingMarkdownContent` 与 `RemoteControlSettingsSheet` 的监听迁为 `@Monitor`。 +本轮没有字段符合“只接收一次父级初值、之后完全由子组件持有”的语义,因此没有使用 +`@Param @Once`。HAP 编译同时验证 `@Param` 未被子组件赋值,架构门禁中的 V1 清单 +已经为空。HAP、LocalTest、窄屏启动与 Local → Remote → Local 往返均通过。 + +--- + +### S6 · 纯化组件层(3–4 天,纯视觉风险) + +**做什么** + +1. 侧栏和工具列表的重复 glyph 已分别收口到 `SidebarGlyphs.ets`、`ToolGlyphs.ets`; +2. 按视觉关注点拆出 `ConnectAccountDevicePage`(账号设备选择)、 + `ChatMessageContent`(图片/Markdown/文件卡片)两个 V2 子组件, + `AppSidebar` 从 908 行降至 700 行,`ConnectView` 从 1344 行降至 1055 行。 + `ToolStatusList` 的业务分组和交互状态仍保留在原 owner,避免纯视觉迁移改变工具动作时序。 +3. S1 同时完成根展示面的纯视觉拆分:Remote、窄屏路由、宽屏会话和 overlay 已由 + 四组 V2 host/surface 组件持有,`AppRootPresentation` 当前为 406 行。 +4. 第二批拆分已落地:`ConnectManualPairingOverlay` 持有手工配对表单, + `ToolInteractionPanels` 持有工具 JSON 编辑/批准和问答草稿,`ChatMessageChrome` + 持有用户气泡、重试提示和流式三点动画。对应主文件当前分别为 + `ConnectView` 695 行、`ToolStatusList` 1106 行、`ChatMessageBubble` 972 行;预算已写入 + `pnpm run harmony:architecture`,禁止展示职责回流。 + +**目标已按 §3.4 修正**:不包含"切断 `components → state`"——该 import 合法。**也不再包含装饰器统一**——S5 已完成,本阶段拆出的新组件天然是 V2。 + +**风险**:纯视觉回归。**每一步必须真机截图,窄屏 + 宽屏 × 浅色 + 深色四组**;所有颜色走 `Theme.ets` 语义 token,`pnpm run theme:color-audit:all` 必须干净。 + +**实际进度(2026-08-07,进行中)**:已完成窄屏浅色启动、侧栏展开、 +Local → Remote → Local 往返截图;新接入 HUAWEI MatePad Pro `WEB-W00` +(2880 × 1920),已安装本轮 HAP,并完成 Pad 浅色/深色下 Local、Remote Home 和连接 +设备面板截图,应用进程持续存活。宽屏合同不等于 Pad 合同:现有 +`ConversationLayoutPolicy` 同时读取零/一/两道纵向折痕,两道折痕的三折叠继续使用 +“左屏 master + 中/右两屏同一个 detail”,正文与关键热区选择不跨第二道折痕的最宽 +内容带;零/一/两道折痕、非对称三屏和非法折痕均有 LocalTest 覆盖。 + +三折叠完整展开及双屏/三屏动态切换仍需要真实两折痕设备验证,Pad 不能替代该项; +文件预览打开/关闭矩阵也尚未闭合,因此 S6 仍不能标记为完成。 + +--- + +### S7 · 关闭护栏(0.5 天) + +原 3 处 `services/` → `pages/` 反向依赖已在 S1/S3 的文件归属迁移中清零;当前 +`pnpm run harmony:architecture` 的 `serviceToPages`、`componentToViewmodel`、 +`viewmodelToComponents` 均为空,V1 清单也为空。门禁已从基线清单切换为永久空集, +并补齐了 `AGENTS.md` 与 `ArchitectureUnit` 的归属说明。 + +--- + +## 6. 每阶段固定验证回路 + +```bash +source scripts/ohos-env.sh + +# 1. 构建 +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon + +# 2. 本地单元测试(6612 行 hypium 用例) +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon + +# 3. 颜色审计 +pnpm run theme:color-audit:all + +# 4. 真机验证(折叠设备 5ZU0226202001116) +hdc -t 5ZU0226202001116 shell snapshot_display -f /data/local/tmp/s.jpeg +hdc -t 5ZU0226202001116 file recv /data/local/tmp/s.jpeg ./s.jpeg +``` + +设备侧注意事项(已踩过的坑): + +- bundle 名是 **`com.example.bitfun_mobile`**,不是 `com.bitfun.mobile`; +- `hdc` 必须带 `-t `,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target); +- 外屏分辨率 1080×2444;点击用 `hdc -t shell uinput -T -c X Y`。 + +**真机验证的最低集合**(每阶段都要过):窄屏抽屉 Local ↔ Remote 来源切换、宽屏三栏、文件预览打开/关闭、深浅色各一轮。 + +--- + +## 7. 明确不做的事 + +- **不引入三层架构(products / features / commons)**。理由见 §1.2:单 entry 模块,收益为零、构建复杂度为正。 +- **不引入新的状态管理库或跨端抽象层**。问题是组织方式,不是工具。 +- **不重构 `services/general-chat/`(21 文件 / 3296 行)内部结构**。它自身分层是干净的,只需在 S7 切断对 `pages/` 的反向依赖。 +- **不追求行数目标本身**。S1 + S2 净减约 800 行是副产品;真正的收益是"改一处不用改三处"和"违规能被 CI 挡住"。 + +> 初版方案曾把"V1 → V2 全量迁移"列在本节。该判断已推翻——理由见 §5 的 S5 阶段,迁移已提升为独立阶段。 + +--- + +## 8. 遗留事项 + +- **窄屏"刷新"与"助手选择"入口缺失**(baseline `6c35485bb` 引入)。删除 `RemoteHomeView.ets` 统一窄屏 Remote 界面时,这两个入口一并移除,宽屏本来就没有。待定:是否补进共享侧栏的 `...` 菜单。此项与本重构无依赖关系,可独立处理。 +- **S6 组件纯化尚未完成**。优先继续拆分 `ToolStatusList`、`ChatMessageBubble` 与 + `ConnectView`,每次拆分保持动作 owner 和时序不变。 +- **视觉验证矩阵尚未闭合**。仍需补窄屏深色、文件预览打开/关闭,以及宽屏三栏的 + 深浅色截图;后者等待可用的展开折叠屏或平板 target。 diff --git a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md index 35e2db0e71..a2296504cc 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -6,7 +6,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ## 实施状态 -截至 2026-07-30: +截至 2026-08-07: ### 已实现 @@ -35,6 +35,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 在同一设备的折叠单屏态(`1080 x 2444`)验证:页面保持原单屏头部和 Composer,可打开原侧边栏;点击 `Remote` 继续打开原“选择桌面设备”Sheet,系统返回可关闭 Sheet 并恢复本地 Home;本地历史会话的显示保持原样。 - 折叠单屏连接已有在线桌面后验证:远程 Home 保留原头部、菜单和会话列表;进入已有远程会话后保留原会话头部与 Composer;系统返回从远程会话回到远程 Home;打开原侧边栏并选择本地会话可恢复本地内容。全程未发送消息、运行命令或启动远程任务。 - 单屏根 `ChatHome` 的系统返回基线已核实:历史本地会话仍投影在根路由,侧边栏可见时也未接入根返回拦截,因此返回会退出 Ability。本次宽屏改动不改变该行为;是否优化应作为独立单屏导航问题处理。 +- 在 HUAWEI MatePad Pro(`WEB-W00`,`2880 x 1920`)安装最新 HAP,浅色与深色均验证本地/Remote 来源选择器、常驻 master、Remote 未连接占位和居中的连接设备面板;布局边界稳定,应用进程持续存活。Pad 验证只覆盖无折痕宽屏,不替代下述三折叠真机项。 ### 待验证 @@ -450,6 +451,7 @@ MasterDetail -> 双屏和三屏共同使用的 master-detail | 展开宽屏,本地会话 | 来源选择器保持“本地”,会话选中态正确,右侧显示当前会话 | | 展开宽屏,远程 Home | 来源选择器选中“Remote”,可一步切回本地,不显示全局侧边栏按钮 | | 展开宽屏,远程会话 | 来源选择器保持“Remote”,会话选中态正确,右侧显示当前会话,不显示全局侧边栏按钮 | +| 宽屏点击远程会话 | 左侧立即选中新会话;右侧立即进入该会话,慢加载时显示时间线骨架,完成后原位替换为历史消息 | | 三屏完整展开,本地会话 | 左屏显示本地 master,中间和右侧共同显示一个本地 detail | | 三屏完整展开,远程会话 | 左屏显示远程 master,中间和右侧共同显示一个远程 detail | | 三屏远程断开 | 左屏仍显示来源选择器,右侧两屏显示一个连续断开状态 | diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 4a918697db..3c22ccc445 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,9 +1,9 @@ import { AppRootPresentation } from './components/AppRootPresentation'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; -import { AppRootRuntime } from './state/AppRootRuntime'; +import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry -@Component +@ComponentV2 struct AppRoot { private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -38,7 +38,7 @@ struct AppRoot { remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, - deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), + deviceId: this.runtime.remoteConnectionController.getDeviceId(), actions: this.runtime.presentationActions }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets new file mode 100644 index 0000000000..63fecd397e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -0,0 +1,152 @@ +import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConversationIntent } from './ConversationIntent'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; + +export interface AppRootPresentationActions { + readonly onNavigationBack: (route: AppRoute) => boolean; + readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; + readonly onCloseSidebar: () => void; + readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactConversationSource: (source: ConversationSource) => void; + readonly onCompactLayoutEntered: () => void; + readonly onLayoutModeChanged: (wideLayout: boolean) => void; + readonly onRemoteHome: RemoteHomePresentationActions; + readonly onRemoteCreate: RemoteCreatePresentationActions; + readonly onSidebar: SidebarPresentationActions; + readonly onSettings: SettingsPresentationActions; + readonly onConnect: ConnectPresentationActions; + readonly onFilePreview: FilePreviewPresentationActions; + readonly generalStatus: () => string; +} + +export interface FilePreviewPresentationActions { + readonly close: () => void; + readonly refresh: () => void; + readonly download: (path: string) => void; + readonly openLink: (reference: string, label: string) => void; +} + +export interface RemoteCreatePresentationActions { + readonly back: () => void; + readonly toggleDevices: () => void; + readonly toggleWorkspaces: () => void; + readonly selectDevice: (device: CloudAccountDevice) => void; + readonly selectWorkspace: (path: string) => void; + readonly draftChanged: (value: string) => void; + readonly voiceInput: () => void; + readonly selectModel: (modelId: string) => void; + readonly send: () => void; +} + +export interface RemoteHomePresentationActions { + readonly openSidebar: () => void; + readonly connectWorkspace: () => void; + readonly addConnection: () => void; + readonly openSettings: () => void; + readonly refresh: () => void; + readonly showWorkspaces: () => void; + readonly showAssistants: () => void; + readonly selectWorkspace: (path: string) => void; + readonly selectAssistant: (path: string) => void; + readonly cancelWorkspace: () => void; + readonly cancelAssistant: () => void; + readonly queryChanged: (query: string) => void; + readonly search: () => void; + readonly loadMore: () => void; + readonly reconnect: () => void; + readonly disconnect: () => void; + readonly clearPairing: () => void; + readonly create: (agentType: string) => void; + readonly createInPlace: (agentType: string) => void; + readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; + readonly openSession: (session: RemoteSession) => void; + readonly openSessionInPlace: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SidebarPresentationActions { + readonly close: () => void; + readonly newChat: () => void; + readonly enterCode: () => void; + readonly settings: () => void; + readonly openAccount: () => void; + readonly openSession: (session: RemoteSession) => void; + readonly archive: (session: RemoteSession, archived: boolean) => void; + readonly exportSession: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SettingsPresentationActions { + readonly close: () => void; + readonly addConnection: () => void; + readonly disconnect: () => void; + readonly reconnect: () => void; + readonly openAccount: () => void; + readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudSync: () => Promise; + readonly cloudLogout: () => Promise; + readonly cloudListDevices: () => Promise; + readonly getPermissionMode: () => Promise; + readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; + readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; +} + +export interface ConnectPresentationActions { + readonly back: () => void; + readonly connect: (password?: string) => void; + readonly clearPairing: () => void; + readonly urlChanged: (url: string) => void; + readonly userChanged: (user: string) => void; + readonly detected: (url: string) => boolean; + readonly inputVisible: (visible: boolean) => void; + readonly paste: () => void; + readonly scan: () => void; + readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; +} + +export function emptyAppRootPresentationActions(): AppRootPresentationActions { + return { + onNavigationBack: () => false, + onConversationIntent: () => {}, + onCloseSidebar: () => {}, + onWideConversationSource: () => {}, + onCompactConversationSource: () => {}, + onCompactLayoutEntered: () => {}, + onLayoutModeChanged: () => {}, + onRemoteHome: { + openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, + refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, + selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, + search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, + create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, + createInWorkspaceInPlace: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} + }, + onRemoteCreate: { + back: () => {}, toggleDevices: () => {}, toggleWorkspaces: () => {}, selectDevice: () => {}, + selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, send: () => {} + }, + onSidebar: { + close: () => {}, newChat: () => {}, enterCode: () => {}, settings: () => {}, openAccount: () => {}, + openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} + }, + onSettings: { + close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, + cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {}, + cloudListDevices: async () => [], getPermissionMode: async () => 'ask', + setPermissionMode: async (mode: RemotePermissionMode) => mode, + testGeneral: async () => '', saveGeneral: async () => '' + }, + onConnect: { + back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {}, + detected: () => false, inputVisible: () => {}, paste: () => {}, scan: () => {}, + cloudListDevices: async () => [], cloudSelectDevice: async () => {} + }, + onFilePreview: { close: () => {}, refresh: () => {}, download: () => {}, openLink: () => {} }, + generalStatus: () => '' + }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets similarity index 94% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index 6531352eeb..76dc0a66e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,5 +1,5 @@ -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; -import { FilePreviewRequest } from '../state/FilePreviewTarget'; +import { ConversationUiQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets similarity index 67% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 0c2c8bbf7e..a07455141e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,10 +1,10 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; +import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; -import { FilePreviewRequest } from './FilePreviewTarget'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; -export class ConversationIntentDispatcherHooks { +export interface ConversationIntentDispatcherHooks { readonly openSidebar: () => void; readonly back: () => void; readonly newRemoteSession: () => void; @@ -35,33 +35,6 @@ export class ConversationIntentDispatcherHooks { readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; - - constructor( - openSidebar: () => void, back: () => void, newRemoteSession: () => void, newGeneralSession: () => void, - activeGeneralSession: () => RemoteSession, activeGeneralSessionId: () => string, - isGeneralBusy: () => boolean, isPinned: (id: string) => boolean, - pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise, - archive: (session: RemoteSession) => Promise, deleteSession: (session: RemoteSession) => Promise, - showToast: (text: string) => void, uploadedFileCount: () => number, - stop: () => Promise, loadOlder: () => Promise, approve: (id: string, input?: Object) => Promise, - reject: (id: string) => Promise, cancel: (id: string) => Promise, - answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, - copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, - pickImages: () => Promise, removeImage: (id: string) => void, - openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void, downloadFile: (path: string) => void, - send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void - ) { - this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; - this.newGeneralSession = newGeneralSession; this.activeGeneralSession = activeGeneralSession; - this.activeGeneralSessionId = activeGeneralSessionId; this.isGeneralBusy = isGeneralBusy; - this.isPinned = isPinned; this.pin = pin; this.archive = archive; this.delete = deleteSession; - this.showToast = showToast; this.uploadedFileCount = uploadedFileCount; this.stop = stop; - this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; - this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; - this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; - this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; - this.voiceInput = voiceInput; this.inputChanged = inputChanged; - } } export class ConversationIntentDispatcher { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets new file mode 100644 index 0000000000..734b9f7ea4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -0,0 +1,185 @@ +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConnectView } from './ConnectView'; +import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SettingsSheet } from './SettingsSheet'; + +@ComponentV2 +export struct AppSidebarSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + AppSidebar({ + sessions: this.source() === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.source() === ConversationSource.Remote ? '' : + (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? + this.generalPageState.conversation.activeSession.sessionId : ''), + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: this.source() === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showViewSettingsButton: this.source() === ConversationSource.Remote, + showCustomContent: this.source() === ConversationSource.Remote, + conversationSource: this.source(), + contentSlot: () => { + this.RemoteContent() + }, + onClose: this.actions.onSidebar.close, + onNewChat: () => this.newChat(), + onEnterCode: this.actions.onSidebar.enterCode, + onConversationSource: this.actions.onCompactConversationSource, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (this.source() === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: () => this.openSettings(), + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + + @Builder + private RemoteContent() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession: true, + compact: true + }) + } + + private source(): ConversationSource { + return AppRouteContract.conversationSource(this.shellState.activeRoute); + } + + private newChat(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createAssistant(); + } else { + this.actions.onSidebar.newChat(); + } + } + + private openSettings(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.openSettings(); + } else { + this.actions.onSidebar.settings(); + } + } +} + +@ComponentV2 +export struct AppSettingsSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { + RemoteControlSettingsSheet({ + desktopName: this.remotePageState.desktopName, + desktopId: this.remotePageState.desktopId, + userId: this.remotePageState.userId, + accountUsername: this.remotePageState.accountUsername, + accountUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + controlTargetType: this.remotePageState.controlTargetType, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + connectionState: this.remotePageState.connectionState, + statusText: this.remotePageState.conversation.statusText, + isBusy: this.remotePageState.conversation.isBusy, + onClose: this.actions.onSettings.close, + onOpenAccount: this.actions.onSettings.openAccount, + onAddConnection: this.actions.onSettings.addConnection, + cloudLogin: this.actions.onSettings.cloudLogin, + cloudSync: this.actions.onSettings.cloudSync, + cloudLogout: this.actions.onSettings.cloudLogout, + cloudListDevices: this.actions.onSettings.cloudListDevices, + getPermissionMode: this.actions.onSettings.getPermissionMode, + setPermissionMode: this.actions.onSettings.setPermissionMode, + openAccountOnAppear: this.shellState.settingsMode === 'account', + onDisconnect: this.actions.onSettings.disconnect, + onReconnect: this.actions.onSettings.reconnect + }) + } else { + SettingsSheet({ + generalChatApiUrl: this.generalPageState.apiUrl, + generalChatModelName: this.generalPageState.modelName, + hasGeneralChatApiKey: this.generalPageState.hasApiKey, + generalChatModelCatalog: this.generalPageState.conversation.modelCatalog, + selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, + accountUsername: this.remotePageState.accountUsername, + authenticatedUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + onOpenAccount: this.actions.onSettings.openAccount, + onTestGeneralChatConfig: this.actions.onSettings.testGeneral, + onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, + onClose: this.actions.onSettings.close + }) + } + } +} + +@ComponentV2 +export struct AppConnectSurface { + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + ConnectView({ + remoteUrl: this.remotePageState.remoteUrl, + userId: this.remotePageState.userId, + statusText: this.remotePageState.conversation.statusText, + connectionState: this.remotePageState.connectionState, + connectionFailureKind: this.remotePageState.connectionFailureKind, + isBusy: this.remotePageState.conversation.isBusy, + isConnected: this.remotePageState.connectionState === 'connected', + desktopName: this.remotePageState.desktopName, + deviceId: this.deviceId, + accountUserId: this.remotePageState.accountUserId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + requiresAccountAuth: this.remotePageState.requiresAccountAuth, + accountUsername: this.remotePageState.accountUsername, + startWithScanner: true, + onBack: this.actions.onConnect.back, + onConnect: this.actions.onConnect.connect, + onRemoteUrlChange: this.actions.onConnect.urlChanged, + onUserIdChange: this.actions.onConnect.userChanged, + onRemoteUrlDetected: this.actions.onConnect.detected, + onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, + cloudListDevices: this.actions.onConnect.cloudListDevices, + cloudSelectDevice: this.actions.onConnect.cloudSelectDevice + }) + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index c38cd9de27..cd5564c45f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,47 +1,42 @@ import display from '@ohos.display'; import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; -import { AppSidebar } from './AppSidebar'; -import { ConnectView } from './ConnectView'; -import { ConversationIntent } from './ConversationIntent'; -import { ComposerPresentation } from './ComposerBar'; -import { ConversationViewSettings } from './ConversationViewSettings'; -import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; -import { GeneralChatHeader } from './GeneralChatHeader'; -import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; -import { RemoteCreateSessionView } from './RemoteCreateSessionView'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { SidebarToggleButton } from './SidebarToggleButton'; -import { SessionActionPresentation } from './SessionActionSurface'; -import { SettingsSheet } from './SettingsSheet'; -import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; -import { AppRoute, AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { PAGE_BG } from './Theme'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; import { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../state/ConversationLayoutPolicy'; +} from '../policy/ConversationLayoutPolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ConversationViewState } from '../state/ConversationViewState'; -import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewLayout, FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../state/FilePreviewPlacementPolicy'; - -const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; +} from '../policy/FilePreviewPlacementPolicy'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { WideConversationHost } from './WideConversationHost'; +import { + AppConnectSurface, + AppSettingsSurface, + AppSidebarSurface +} from './AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; function safeFoldStatus(): display.FoldStatus { try { @@ -59,216 +54,6 @@ function safeDeviceType(): string { } } -export class AppRootPresentationActions { - readonly onNavigationBack: (route: AppRoute) => boolean; - readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; - readonly onCloseSidebar: () => void; - readonly onWideConversationSource: (source: ConversationSource) => void; - readonly onCompactConversationSource: (source: ConversationSource) => void; - readonly onCompactLayoutEntered: () => void; - readonly onRemoteHome: RemoteHomePresentationActions; - readonly onRemoteCreate: RemoteCreatePresentationActions; - readonly onSidebar: SidebarPresentationActions; - readonly onSettings: SettingsPresentationActions; - readonly onConnect: ConnectPresentationActions; - readonly onFilePreview: FilePreviewPresentationActions; - readonly generalStatus: () => string; - - constructor( - onNavigationBack: (route: AppRoute) => boolean, - onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, - onCloseSidebar: () => void, - onWideConversationSource: (source: ConversationSource) => void, - onCompactConversationSource: (source: ConversationSource) => void, - onCompactLayoutEntered: () => void, - onRemoteHome: RemoteHomePresentationActions, - onRemoteCreate: RemoteCreatePresentationActions, - onSidebar: SidebarPresentationActions, - onSettings: SettingsPresentationActions, - onConnect: ConnectPresentationActions, - onFilePreview: FilePreviewPresentationActions, - generalStatus: () => string - ) { - this.onNavigationBack = onNavigationBack; - this.onConversationIntent = onConversationIntent; - this.onCloseSidebar = onCloseSidebar; - this.onWideConversationSource = onWideConversationSource; - this.onCompactConversationSource = onCompactConversationSource; - this.onCompactLayoutEntered = onCompactLayoutEntered; - this.onRemoteHome = onRemoteHome; - this.onRemoteCreate = onRemoteCreate; - this.onSidebar = onSidebar; - this.onSettings = onSettings; - this.onConnect = onConnect; - this.onFilePreview = onFilePreview; - this.generalStatus = generalStatus; - } -} - -export class FilePreviewPresentationActions { - readonly close: () => void; - readonly refresh: () => void; - readonly download: (path: string) => void; - readonly openLink: (reference: string, label: string) => void; - - constructor( - close: () => void, - refresh: () => void, - download: (path: string) => void, - openLink: (reference: string, label: string) => void - ) { - this.close = close; - this.refresh = refresh; - this.download = download; - this.openLink = openLink; - } -} - -export class RemoteCreatePresentationActions { - readonly back: () => void; - readonly toggleDevices: () => void; - readonly toggleWorkspaces: () => void; - readonly selectDevice: (device: CloudAccountDevice) => void; - readonly selectWorkspace: (path: string) => void; - readonly draftChanged: (value: string) => void; - readonly voiceInput: () => void; - readonly selectModel: (modelId: string) => void; - readonly send: () => void; - - constructor( - back: () => void, - toggleDevices: () => void, - toggleWorkspaces: () => void, - selectDevice: (device: CloudAccountDevice) => void, - selectWorkspace: (path: string) => void, - draftChanged: (value: string) => void, - voiceInput: () => void, - selectModel: (modelId: string) => void, - send: () => void - ) { - this.back = back; - this.toggleDevices = toggleDevices; - this.toggleWorkspaces = toggleWorkspaces; - this.selectDevice = selectDevice; - this.selectWorkspace = selectWorkspace; - this.draftChanged = draftChanged; - this.voiceInput = voiceInput; - this.selectModel = selectModel; - this.send = send; - } -} - -export class RemoteHomePresentationActions { - readonly openSidebar: () => void; readonly connectWorkspace: () => void; - readonly addConnection: () => void; readonly openSettings: () => void; - readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; - readonly selectWorkspace: (path: string) => void; readonly selectAssistant: (path: string) => void; - readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; - readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; - readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; - readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; - readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; - readonly openSession: (session: RemoteSession) => void; - readonly openSessionInPlace: (session: RemoteSession) => void; - readonly deleteSession: (session: RemoteSession) => void; - - constructor( - openSidebar: () => void, connectWorkspace: () => void, addConnection: () => void, openSettings: () => void, - refresh: () => void, showWorkspaces: () => void, showAssistants: () => void, - selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, - cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, - search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, - clearPairing: () => void, create: (agentType: string) => void, createInPlace: (agentType: string) => void, - createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, - createInWorkspaceInPlace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, - openSessionInPlace: (session: RemoteSession) => void, - deleteSession: (session: RemoteSession) => void - ) { - this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; - this.openSettings = openSettings; this.refresh = refresh; this.showWorkspaces = showWorkspaces; - this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; - this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; - this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; - this.clearPairing = clearPairing; this.create = create; this.createInPlace = createInPlace; - this.createAssistant = createAssistant; this.createInWorkspace = createInWorkspace; - this.createInWorkspaceInPlace = createInWorkspaceInPlace; this.openSession = openSession; - this.openSessionInPlace = openSessionInPlace; this.deleteSession = deleteSession; - } -} - -export class SidebarPresentationActions { - readonly close: () => void; readonly newChat: () => void; readonly enterCode: () => void; - readonly settings: () => void; readonly openAccount: () => void; - readonly openSession: (session: RemoteSession) => void; - readonly archive: (session: RemoteSession, archived: boolean) => void; - readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; - constructor( - close: () => void, newChat: () => void, enterCode: () => void, settings: () => void, openAccount: () => void, - openSession: (session: RemoteSession) => void, archive: (session: RemoteSession, archived: boolean) => void, - exportSession: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void - ) { - this.close = close; this.newChat = newChat; this.enterCode = enterCode; this.settings = settings; - this.openAccount = openAccount; - this.openSession = openSession; this.archive = archive; this.exportSession = exportSession; this.deleteSession = deleteSession; - } -} - -export class SettingsPresentationActions { - readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; - readonly reconnect: () => void; - readonly openAccount: () => void; - readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; - readonly cloudSync: () => Promise; - readonly cloudLogout: () => Promise; - readonly cloudListDevices: () => Promise; - readonly getPermissionMode: () => Promise; - readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; - readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - constructor( - close: () => void, addConnection: () => void, disconnect: () => void, reconnect: () => void, - openAccount: () => void, - cloudLogin: (relayUrl: string, username: string, password: string) => Promise, - cloudSync: () => Promise, cloudLogout: () => Promise, - cloudListDevices: () => Promise, - getPermissionMode: () => Promise, - setPermissionMode: (mode: RemotePermissionMode) => Promise, - testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise, - saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise - ) { - this.close = close; this.addConnection = addConnection; this.disconnect = disconnect; - this.reconnect = reconnect; this.openAccount = openAccount; this.cloudLogin = cloudLogin; this.cloudSync = cloudSync; - this.cloudLogout = cloudLogout; this.cloudListDevices = cloudListDevices; - this.getPermissionMode = getPermissionMode; this.setPermissionMode = setPermissionMode; - this.testGeneral = testGeneral; - this.saveGeneral = saveGeneral; - } -} - -export class ConnectPresentationActions { - readonly back: () => void; readonly connect: (password?: string) => void; readonly clearPairing: () => void; - readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; - readonly detected: (url: string) => boolean; readonly inputVisible: (visible: boolean) => void; - readonly paste: () => void; readonly scan: () => void; - readonly cloudListDevices: () => Promise; - readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; - constructor( - back: () => void, connect: (password?: string) => void, clearPairing: () => void, - urlChanged: (url: string) => void, userChanged: (user: string) => void, - detected: (url: string) => boolean, inputVisible: (visible: boolean) => void, - paste: () => void, scan: () => void, cloudListDevices: () => Promise, - cloudSelectDevice: (device: CloudAccountDevice) => Promise - ) { - this.back = back; this.connect = connect; this.clearPairing = clearPairing; - this.urlChanged = urlChanged; this.userChanged = userChanged; this.detected = detected; - this.inputVisible = inputVisible; this.paste = paste; this.scan = scan; - this.cloudListDevices = cloudListDevices; this.cloudSelectDevice = cloudSelectDevice; - } -} - @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @@ -291,14 +76,8 @@ export struct AppRootPresentation { @Local wideMasterPaneCollapsed: boolean = false; @Local wideMasterPaneMotionActive: boolean = false; @Local restoreCollapsedMasterAfterPreview: boolean = false; - @Local remoteWideSortMode: string = 'project'; - @Local remoteWorkspaceFilter: string = ''; - @Local remoteAgentFilter: string = ''; - @Local remoteStatusFilter: string = ''; @Local showRemoteViewSettings: boolean = false; - @Local showRemoteWorkspaceMetadata: boolean = false; - @Local showRemoteUpdatedMetadata: boolean = false; - @Local showRemoteStatusMetadata: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); private readonly deviceType: string = safeDeviceType(); private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -312,19 +91,7 @@ export struct AppRootPresentation { this.wideLayoutMatched = result.matches; this.refreshWideGeometry(); }; - @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, () => {}, () => {}, - new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), - new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, - () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), - new FilePreviewPresentationActions(() => {}, () => {}, () => {}, () => {}), - () => '' - ); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); aboutToAppear(): void { this.bindResponsiveQueries(); @@ -378,423 +145,58 @@ export struct AppRootPresentation { @Builder RouteContent(route: AppRoute) { - if (this.isGeneralWideRoute(route) && this.isWideLayout()) { - this.WideGeneralChatContent(route) - } else if (this.showsWideRemoteConversation(route) && - this.filePreviewPlacement() === FilePreviewPlacement.WideFocusSplit) { - this.WideRemotePreviewFocusContent() - } else if (this.showsWideRemoteConversation(route)) { - this.WideRemoteChatContent() - } else if (route === AppRoute.RemoteHome && this.isWideLayout()) { - this.WideRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate && this.isWideLayout()) { - this.WideRemoteCreateContent() + if (this.isWideLayout() && this.isConversationRoute(route)) { + WideConversationHost({ + route, + shellState: this.shellState, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + filePreviewLayout: this.filePreviewLayout(), + wideMasterPaneWidth: this.wideMasterPaneWidth, + wideMasterDetailGap: this.wideMasterDetailGap, + wideDetailContentOffset: this.wideDetailContentOffset, + wideDetailContentWidth: this.wideDetailContentWidth, + wideCollapsedDetailContentOffset: this.wideCollapsedDetailContentOffset, + wideCollapsedDetailContentWidth: this.wideCollapsedDetailContentWidth, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + wideMasterPaneMotionActive: this.wideMasterPaneMotionActive, + onCollapseMasterPane: () => this.collapseWideMasterPane(), + onRestoreMasterPane: () => this.restoreWideMasterPane(), + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } + }) } else { - this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) - } - } - - @Builder - RouteSurfaceContent( - route: AppRoute, - showSidebarButton: boolean, - showBackButton: boolean, - showSidebarRestoreButton: boolean = false, - useWidePresentation: boolean = false - ) { - Column() { - if (route === AppRoute.RemoteHome) { - this.CompactRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate) { - RemoteCreateSessionView({ - state: this.remoteCreateState, - presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, - isVoiceListening: this.remoteCreateState.isVoiceListening, - modelCatalog: toConversationUiModelCatalog(this.remotePageState.modelCatalog), - selectedModelId: this.remoteCreateState.selectedModelId, - showSidebarRestoreButton: showSidebarRestoreButton, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onBack: this.actions.onRemoteCreate.back, - onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, - onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, - onSelectDevice: this.actions.onRemoteCreate.selectDevice, - onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), - onDraftChange: this.actions.onRemoteCreate.draftChanged, - onVoiceInput: this.actions.onRemoteCreate.voiceInput, - onSelectModel: this.actions.onRemoteCreate.selectModel, - onSend: this.actions.onRemoteCreate.send - }) - } else { - ConversationViewHost({ - viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, - this.actions.generalStatus()), - activeFilePreviewPath: route === AppRoute.RemoteChat && this.filePreviewState.visible ? - this.filePreviewState.target.remotePath : '', - activeFilePreviewLoading: route === AppRoute.RemoteChat && this.filePreviewState.visible && - this.filePreviewState.phase === FilePreviewPhase.Loading, - showSidebarButton: showSidebarButton, - showBackButton: showBackButton, - showSidebarRestoreButton: showSidebarRestoreButton, - composerPresentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, - contentHorizontalOffset: useWidePresentation ? this.collapsedDetailVisualBias() : 0, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) - }) - } - }.width('100%').height('100%').backgroundColor(PAGE_BG) - } - - @Builder - WideGeneralChatContent(route: AppRoute) { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.General, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(route, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteHomeContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - - Column() { - this.RemoteFlowPlaceholder() - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteCreateContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteCreate, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - /** - * The single wide master pane shell. Local and Remote differ only in the - * session content they hand to the shared sidebar, so the header, source - * switcher, content origin and footer never move when the source changes. - */ - @Builder - WideMasterPane(source: ConversationSource, showSelectedSession: boolean) { - Column() { - Column() { - AppSidebar({ - sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: source === ConversationSource.Remote ? '' : - this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showCollapseButton: true, - showViewSettingsButton: source === ConversationSource.Remote, - showCustomContent: source === ConversationSource.Remote, - conversationSource: source, - contentSlot: () => { - this.RemoteMasterContent(showSelectedSession); - }, - onClose: this.actions.onSidebar.close, - onNewChat: source === ConversationSource.Remote ? - this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onCollapse: () => { - this.collapseWideMasterPane(); - }, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (source === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: source === ConversationSource.Remote ? - this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession - }) - } - .width('100%') - .height('100%') - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) - } - .width(this.wideMasterPaneCurrentWidth()) - .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) - } - - /** - * Remote session content for the shared sidebar shell. The wide master pane - * opens sessions in place next to the list; the compact drawer has to close - * itself and navigate, so every entry point is routed through a compact flag - * instead of a second copy of the list. - */ - @Builder - RemoteMasterContent(showSelectedSession: boolean, compact: boolean = false) { - Column() { - this.RemoteStatusRow() - if (this.isRemoteInitialLoading()) { - RemoteSessionLoadingView() - } else if (this.canShowRemoteSessionList()) { - RemoteSessionList({ - sessions: this.remotePageState.visibleSessions(), - query: this.remotePageState.sessionQuery, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - actionPresentation: SessionActionPresentation.Popover, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - hasMoreSessions: this.remotePageState.hasMoreSessions, - isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', - onCreate: () => { - this.createRemoteSession('code', compact); - }, - onCreateAssistantSession: () => { - this.createRemoteAssistantSession(compact); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.createRemoteSessionInWorkspace(path, agentType, compact); - }, - onSelectWorkspace: (path: string) => { - this.actions.onRemoteHome.selectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.openRemoteSession(session, compact); - }, - onDeleteSession: (session: RemoteSession) => { - this.actions.onRemoteHome.deleteSession(session); - }, - onLoadMore: () => { - this.actions.onRemoteHome.loadMore(); - } - }) - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .alignItems(HorizontalAlign.Start) - .padding({ bottom: 84 }) - } - - /** Connection status lives in the remote content, not in the shared header. */ - @Builder - RemoteStatusRow() { - Row({ space: 6 }) { - this.RemoteStatusIndicator() - Text(this.remoteStatusText()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + ConversationRouteSurface({ + route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: true, + // Compact conversations own the drawer, not a back control: Local and + // Remote both open the sidebar over the chat instead of leaving it. + showBackButton: false, + onRestoreSidebar: () => this.restoreWideMasterPane() + }) } - .width('100%') - .margin({ top: 16, bottom: 6 }) - .alignItems(VerticalAlign.Center) } @Builder RemoteViewSettingsSheet() { - ConversationViewSettings({ - sessions: this.remotePageState.visibleSessions(), - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - onSortModeChange: (mode: string) => { - this.remoteWideSortMode = mode; - }, - onWorkspaceFilterChange: (value: string) => { - RemoteLogger.info(`wide view-settings workspace received=${value.length > 0 ? value : ''}`); - this.remoteWorkspaceFilter = value; - }, - onAgentFilterChange: (value: string) => { - this.remoteAgentFilter = value; - }, - onStatusFilterChange: (value: string) => { - this.remoteStatusFilter = value; - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.showRemoteWorkspaceMetadata = value; - }, - onUpdatedMetadataChange: (value: boolean) => { - this.showRemoteUpdatedMetadata = value; - }, - onStatusMetadataChange: (value: boolean) => { - this.showRemoteStatusMetadata = value; - }, - onClose: () => { - this.showRemoteViewSettings = false; - } + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onCloseSettings: () => { this.showRemoteViewSettings = false; } }) } - @Builder - RemoteStatusIndicator() { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - } else { - Stack() { - Text('') - } - .width(7) - .height(7) - .backgroundColor(this.remoteStatusColor()) - .borderRadius(4) - } - } - - @Builder - RemoteDisconnectedState() { - Column({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(42) - .fontColor([INK]) - } - .width(74) - .height(74) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.connect')) - .width(136) - .height(44) - .fontSize(15) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(22) - .onClick(() => { - this.actions.onRemoteHome.connectWorkspace(); - }) - } - .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20, bottom: 48 }) - } - - @Builder - WideRemoteChatContent() { - if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { - Row() { - this.WideMasterPane(ConversationSource.Remote, true) - this.WidePaneGap(this.filePreviewLayout().masterConversationGap) - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } else { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, true) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteChat, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideRemotePreviewFocusContent() { - Row() { - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - @Builder FilePreviewPane(paneWidth: number = 0) { Column() { @@ -816,232 +218,10 @@ export struct AppRootPresentation { .backgroundColor(PAGE_BG) } - @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { - if (paneWidth > 0) { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width(paneWidth) - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } else { - Stack({ alignContent: Alignment.TopStart }) { - Row() { - if (this.currentDetailContentOffset() > 0) { - Blank().width(this.currentDetailContentOffset()) - } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width('100%') - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } - .width(this.currentDetailContentWidth() > 0 ? this.currentDetailContentWidth() : '100%') - .height('100%') - .justifyContent(FlexAlign.Center) - if (this.currentDetailContentOffset() > 0) { - Blank().layoutWeight(1) - } - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) - - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 44, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - .position({ x: this.currentDetailContentOffset() + 12, y: 12 }) - .zIndex(2) - .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 180, curve: Curve.EaseOut })) - } - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideMasterDetailGap() { - if (this.wideMasterDetailGap > 0) { - Row() { - } - .width(this.wideMasterDetailGap) - .height('100%') - .backgroundColor(LINE) - } - } - - @Builder - WidePaneGap(width: number) { - if (width > 0) { - Row() { - } - .width(width) - .height('100%') - .backgroundColor(LINE) - } - } - - /** - * Compact Remote landing surface. The session list lives in the shared drawer - * now, so this route only carries connection state and the way back into the - * drawer — the same shape the Local composer route has. - */ - @Builder - CompactRemoteHomeContent() { - Column() { - GeneralChatHeader({ - title: RemoteI18n.t('remote.title'), - showSidebarButton: true, - onOpenSidebar: this.actions.onRemoteHome.openSidebar - }) - if (this.canShowRemoteSessionList()) { - this.CompactRemoteEmptyState() - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - CompactRemoteEmptyState() { - Column({ space: 10 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.compactRemoteEmptyTitle()) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(this.compactRemoteEmptyText()) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('remote.startSession')) - .width(148) - .height(46) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(23) - .margin({ top: 12 }) - .onClick(() => { - this.actions.onRemoteHome.createAssistant(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 56 }) - } - - @Builder - RemoteFlowPlaceholder() { - Column() { - Row({ space: 8 }) { - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - } else { - Blank().width(48).height(48) - } - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteDesktopName()) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - Blank().width(48).height(48) - } - .width('100%') - .height(76) - .padding({ left: 16, right: 16, top: 14, bottom: 12 }) - .border({ width: { bottom: 1 }, color: LINE }) - - Column({ space: 8 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.remoteFlowPlaceholderTitle()) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteStatusText()) - .fontSize(14) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 48 }) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - private isWideLayout(): boolean { return this.largeScreenLayout; } - /** - * Read inside the master pane builder rather than passed in: a @Builder only - * re-renders on parameters passed by reference, so a width handed over as a - * value would freeze at whatever the pane measured on its first render. - */ - private wideMasterPaneCurrentWidth(): number { - return this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane ? - this.filePreviewLayout().masterPaneWidth : this.wideMasterPaneWidth; - } - private collapseWideMasterPane(): void { if (!this.isWideLayout() || this.filePreviewState.visible) { return; @@ -1067,24 +247,6 @@ export struct AppRootPresentation { }, 240); } - private currentDetailContentOffset(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentOffset : this.wideDetailContentOffset; - } - - private currentDetailContentWidth(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentWidth : this.wideDetailContentWidth; - } - - private collapsedDetailVisualBias(): number { - if (!this.wideMasterPaneCollapsed || this.wideCollapsedDetailContentOffset > 0) { - return 0; - } - const availableMargin = (this.wideCollapsedDetailContentWidth - WIDE_DETAIL_CONTENT_MAX_WIDTH) / 2; - return Math.min(72, Math.max(0, availableMargin)); - } - private filePreviewPlacement(): FilePreviewPlacement { return this.filePreviewLayout().placement; } @@ -1099,16 +261,9 @@ export struct AppRootPresentation { ); } - private isGeneralWideRoute(route: AppRoute): boolean { - return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; - } - - private showsWideRemoteConversation(route: AppRoute): boolean { - if (!this.isWideLayout()) { - return false; - } - return route === AppRoute.RemoteChat || - (route === AppRoute.RemoteHome && this.remotePageState.activeSession.sessionId.length > 0); + private isConversationRoute(route: AppRoute): boolean { + return route === AppRoute.ChatHome || route === AppRoute.GeneralChat || + route === AppRoute.RemoteHome || route === AppRoute.RemoteCreate || route === AppRoute.RemoteChat; } private bindResponsiveQueries(): void { @@ -1153,8 +308,7 @@ export struct AppRootPresentation { } private areaWidth(width: Object): number { - const value = Number.parseFloat(`${width}`); - return Number.isNaN(value) ? 0 : value; + return WideLayoutGeometry.areaLength(width); } private refreshWideGeometry(): void { @@ -1178,6 +332,7 @@ export struct AppRootPresentation { this.wideDetailContentWidth = geometry.detailContentWidth; this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + this.actions.onLayoutModeChanged(this.largeScreenLayout); if (wasWideLayout && !this.largeScreenLayout) { this.actions.onCompactLayoutEntered(); } @@ -1202,126 +357,6 @@ export struct AppRootPresentation { } } - /** - * Session entry points shared by the wide master pane and the compact drawer. - * The wide pane keeps the list on screen and swaps the detail pane; the - * compact drawer has to dismiss itself first and then navigate. - */ - private openRemoteSession(session: RemoteSession, compact: boolean): void { - if (compact) { - this.actions.onSidebar.openSession(session); - return; - } - this.actions.onRemoteHome.openSessionInPlace(session); - } - - private createRemoteSession(agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.create(agentType); - return; - } - this.actions.onRemoteHome.createInPlace(agentType); - } - - private createRemoteSessionInWorkspace(path: string, agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType); - return; - } - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); - } - - private createRemoteAssistantSession(compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - } - this.actions.onRemoteHome.createAssistant(); - } - - private compactSidebarSource(): ConversationSource { - return AppRouteContract.conversationSource(this.shellState.activeRoute); - } - - /** The compact drawer's new-chat and settings entries follow the active source. */ - private compactSidebarNewChat(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.createRemoteAssistantSession(true); - return; - } - this.actions.onSidebar.newChat(); - } - - private compactSidebarSettings(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.openSettings(); - return; - } - this.actions.onSidebar.settings(); - } - - private canShowRemoteSessionList(): boolean { - return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || - this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; - } - - private isRemoteInitialLoading(): boolean { - return this.remotePageState.isLoadingHome || this.isRemoteConnecting(); - } - - private isRemoteConnecting(): boolean { - return this.remotePageState.connectionState === 'parsing' || - this.remotePageState.connectionState === 'pairing' || - this.remotePageState.connectionState === 'reconnecting'; - } - - private remoteStatusText(): string { - if (this.remotePageState.statusText.length > 0) { - return this.remotePageState.statusText; - } - return this.remoteDesktopName(); - } - - private remoteStatusColor(): ResourceColor { - if (this.remotePageState.connectionState === 'connected') { - return GREEN; - } - if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { - return RED; - } - return MUTED; - } - - private remoteDesktopName(): string { - return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : - RemoteI18n.t('remote.settings.noDesktop'); - } - - private compactRemoteEmptyTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); - } - - private compactRemoteEmptyText(): string { - if (this.isRemoteInitialLoading()) { - return this.remoteStatusText(); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); - } - - private remoteFlowPlaceholderTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); - } - private remoteViewSettingsSheetOptions(): SheetOptions { if (!this.isWideLayout()) { return { @@ -1343,107 +378,32 @@ export struct AppRootPresentation { }; } - /** - * The compact drawer runs the same sidebar shell as the wide master pane, so - * Local and Remote are two sources inside one session container instead of a - * drawer and a separate destination page. The drawer outlives every route - * change, and a @Builder does not re-render on value parameters, so the source - * is read from the current route on each render instead of being passed in. - */ @Builder SidebarContent() { - AppSidebar({ - sessions: this.compactSidebarSource() === ConversationSource.Remote ? - [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.compactSidebarSource() === ConversationSource.Remote ? '' : - (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? - this.generalPageState.activeSession.sessionId : ''), - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: this.compactSidebarSource() === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showViewSettingsButton: this.compactSidebarSource() === ConversationSource.Remote, - showCustomContent: this.compactSidebarSource() === ConversationSource.Remote, - conversationSource: this.compactSidebarSource(), - contentSlot: () => { - this.RemoteMasterContent(true, true); - }, - onClose: this.actions.onSidebar.close, - onNewChat: () => { - this.compactSidebarNewChat(this.compactSidebarSource()); - }, - onEnterCode: this.actions.onSidebar.enterCode, - onConversationSource: this.actions.onCompactConversationSource, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (this.compactSidebarSource() === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: () => { - this.compactSidebarSettings(this.compactSidebarSource()); - }, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } }) } @Builder SettingsContent() { - if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { - RemoteControlSettingsSheet({ desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, - userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, - accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, - controlTargetType: this.remotePageState.controlTargetType, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - connectionState: this.remotePageState.connectionState, statusText: this.remotePageState.statusText, - isBusy: this.remotePageState.isBusy, onClose: this.actions.onSettings.close, - onOpenAccount: this.actions.onSettings.openAccount, - onAddConnection: this.actions.onSettings.addConnection, - cloudLogin: this.actions.onSettings.cloudLogin, - cloudSync: this.actions.onSettings.cloudSync, - cloudLogout: this.actions.onSettings.cloudLogout, - cloudListDevices: this.actions.onSettings.cloudListDevices, - getPermissionMode: this.actions.onSettings.getPermissionMode, - setPermissionMode: this.actions.onSettings.setPermissionMode, - openAccountOnAppear: this.shellState.settingsMode === 'account', - onDisconnect: this.actions.onSettings.disconnect, onReconnect: this.actions.onSettings.reconnect }) - } else { - SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, - hasGeneralChatApiKey: this.generalPageState.hasApiKey, - generalChatModelCatalog: this.generalPageState.modelCatalog, - selectedGeneralChatModelId: this.generalPageState.selectedModelId, - accountUsername: this.remotePageState.accountUsername, - authenticatedUserId: this.remotePageState.accountUserId, + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, deviceId: this.deviceId, - onOpenAccount: this.actions.onSettings.openAccount, - onTestGeneralChatConfig: this.actions.onSettings.testGeneral, - onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, - onClose: this.actions.onSettings.close }) - } + actions: this.actions + }) } @Builder ConnectContent() { - ConnectView({ remoteUrl: this.remotePageState.remoteUrl, userId: this.remotePageState.userId, - showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, statusText: this.remotePageState.statusText, - connectionState: this.remotePageState.connectionState, connectionFailureKind: this.remotePageState.connectionFailureKind, - isBusy: this.remotePageState.isBusy, isConnected: this.remotePageState.connectionState === 'connected', - desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, - accountUserId: this.remotePageState.accountUserId, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, - startWithScanner: true, - onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, - onClearPairing: this.actions.onConnect.clearPairing, onRemoteUrlChange: this.actions.onConnect.urlChanged, - onUserIdChange: this.actions.onConnect.userChanged, onRemoteUrlDetected: this.actions.onConnect.detected, - onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, - onPasteRemoteUrl: this.actions.onConnect.paste, onScanRemoteUrl: this.actions.onConnect.scan, - cloudListDevices: this.actions.onConnect.cloudListDevices, - cloudSelectDevice: this.actions.onConnect.cloudSelectDevice }) - .width('100%').height('100%') + AppConnectSurface({ + remotePageState: this.remotePageState, + deviceId: this.deviceId, + actions: this.actions + }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 08cec8c99d..6e02ff01b0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -5,43 +5,44 @@ import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; +import { SidebarGlyph } from './SidebarGlyphs'; -@Component +@ComponentV2 export struct AppSidebar { - @Prop sessions: RemoteSession[] = []; - @Prop pinnedSessionId: string = ''; - @Prop selectedSessionId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop activeSection: string = 'chat'; - @Prop accountUserId: string = ''; - @Prop showConversationSourceSwitcher: boolean = false; - @Prop showCollapseButton: boolean = false; - @Prop showViewSettingsButton: boolean = false; - @Prop showCustomContent: boolean = false; - @Prop conversationSource: ConversationSource = ConversationSource.General; - onClose: () => void = () => {}; - onNewChat: () => void = () => {}; - onEnterCode: () => void = () => {}; - onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; - onCollapse: () => void = () => {}; - onOpenViewSettings: () => void = () => {}; - onSearchQueryChange: (query: string) => void = (_query: string) => {}; - onOpenSettings: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onArchiveSession: (session: RemoteSession, archived: boolean) => void = + @Param sessions: RemoteSession[] = []; + @Param pinnedSessionId: string = ''; + @Param selectedSessionId: string = ''; + @Param connectionState: string = 'idle'; + @Param activeSection: string = 'chat'; + @Param accountUserId: string = ''; + @Param showConversationSourceSwitcher: boolean = false; + @Param showCollapseButton: boolean = false; + @Param showViewSettingsButton: boolean = false; + @Param showCustomContent: boolean = false; + @Param conversationSource: ConversationSource = ConversationSource.General; + @Event onClose: () => void = () => {}; + @Event onNewChat: () => void = () => {}; + @Event onEnterCode: () => void = () => {}; + @Event onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Event onCollapse: () => void = () => {}; + @Event onOpenViewSettings: () => void = () => {}; + @Event onSearchQueryChange: (query: string) => void = (_query: string) => {}; + @Event onOpenSettings: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onArchiveSession: (session: RemoteSession, archived: boolean) => void = (_session: RemoteSession, _archived: boolean) => {}; - onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @State activeActionSessionId: string = ''; - @State showSessionActionSheet: boolean = false; - @State detailsSessionId: string = ''; - @State showSessionDetails: boolean = false; - @State showSearch: boolean = false; - @State sessionSearchQuery: string = ''; - @State archivedSessionsExpanded: boolean = false; + @Event onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; + @Local showSearch: boolean = false; + @Local sessionSearchQuery: string = ''; + @Local archivedSessionsExpanded: boolean = false; /** * Session content for the current conversation source. The shell around it * (header, source switcher, content origin, footer) stays identical for every @@ -180,7 +181,7 @@ export struct AppSidebar { Row({ space: 6 }) { if (this.showViewSettingsButton) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(38) .height(38) @@ -195,7 +196,7 @@ export struct AppSidebar { } Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() + SidebarGlyph({ kind: 'search' }) } .width(38) .height(38) @@ -282,7 +283,7 @@ export struct AppSidebar { private AuthenticatedFooter() { Row() { Row({ space: 9 }) { - this.EditGlyph() + SidebarGlyph({ kind: 'edit' }) Text(RemoteI18n.t('sidebar.newChat')) .fontSize(15) .fontWeight(FontWeight.Medium) @@ -303,7 +304,7 @@ export struct AppSidebar { Blank() Stack({ alignContent: Alignment.Center }) { - this.SettingsGlyph() + SidebarGlyph({ kind: 'settings' }) } .width(46) .height(46) @@ -338,7 +339,7 @@ export struct AppSidebar { @Builder NavRow(label: string, isActive: boolean, action: () => void) { Row({ space: 14 }) { - this.RemoteGlyph() + SidebarGlyph({ kind: 'remote', connectionState: this.connectionState }) Text(label) .fontSize(18) .fontWeight(FontWeight.Bold) @@ -476,21 +477,10 @@ export struct AppSidebar { }) } - @Builder - private MoreDotsGlyph() { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) - } - @Builder private SessionMoreButton(session: RemoteSession) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(34) .height(40) @@ -558,205 +548,6 @@ export struct AppSidebar { .padding({ top: 8, bottom: 8 }) } - @Builder - RemoteGlyph() { - Stack({ alignContent: Alignment.Center }) { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) - Text('') - .width(8) - .height(8) - .backgroundColor(GREEN) - .borderRadius(4) - .position({ x: 24, y: 22 }) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(MUTED) - } - } - .width(35) - .height(34) - } - - @Builder - SearchGlyph() { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - NotebookGlyph() { - Stack() { - Text('') - .width(22) - .height(24) - .borderRadius(5) - .border({ width: 1.5, color: INK }) - .position({ x: 8, y: 5 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 11 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - ClockGlyph() { - Stack() { - Text('') - .width(26) - .height(26) - .borderRadius(13) - .border({ width: 1.5, color: INK }) - .position({ x: 4, y: 4 }) - Text('') - .width(1.5) - .height(9) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 10 }) - Text('') - .width(9) - .height(1.5) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - AppsGlyph() { - Column({ space: 8 }) { - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - } - .width(24) - .height(24) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - AppDot() { - Text('') - .width(8) - .height(8) - .borderRadius(4) - .backgroundColor(INK) - } - - @Builder - CodeFlowerGlyph() { - Stack() { - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 1 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 17, y: 9 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 17 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 1, y: 9 }) - Text('') - .width(14) - .height(14) - .borderRadius(7) - .backgroundColor(CARD) - .position({ x: 10, y: 10 }) - } - .width(34) - .height(34) - } - - @Builder - MoreGlyph() { - Row({ space: 5 }) { - this.DotGlyph() - this.DotGlyph() - this.DotGlyph() - } - .width(30) - .height(22) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - - @Builder - DotGlyph() { - Text('') - .width(5) - .height(5) - .borderRadius(3) - .backgroundColor(INK) - } - - @Builder - EditGlyph() { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - SettingsGlyph() { - SymbolGlyph($r('sys.symbol.gearshape')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - private visibleRecentSessions(): RemoteSession[] { const query = this.sessionSearchQuery.trim().toLowerCase(); return this.sessions.filter((session: RemoteSession) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 38375e4387..c1a6917fd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -2,17 +2,17 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct BitFunAccountLoginPage { - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - onBack: () => void = () => {}; - onLoginSuccess: () => void = () => {}; - @State relayUrl: string = DEFAULT_CLOUD_RELAY_URL; - @State username: string = ''; - @State password: string = ''; - @State errorText: string = ''; - @State isBusy: boolean = false; + @Event onBack: () => void = () => {}; + @Event onLoginSuccess: () => void = () => {}; + @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; + @Local username: string = ''; + @Local password: string = ''; + @Local errorText: string = ''; + @Local isBusy: boolean = false; build() { Stack({ alignContent: Alignment.TopStart }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index b2b3e8531c..66628abaa6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,16 +1,10 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; -import { FileReferenceCard } from './FileReferenceCard'; -import { MarkdownContent } from './MarkdownContent'; -import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { INK, LINE, MUTED, SOFT } from './Theme'; +import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; +import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; -import { FileTargetResolver } from '../../services/FileTargetResolver'; -import { - MessageFileReference, - MessageFileReferenceProjectionCache -} from '../../services/MessageFileReferenceProjector'; +import { MessageFileReference, MessageFileReferenceProjectionCache } from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -28,13 +22,6 @@ interface StructuredRenderGroup { path: string; } -interface ActivityGroupStats { - thinkingCount: number; - readCount: number; - searchCount: number; - otherCount: number; -} - @ComponentV2 export struct ChatMessageBubble { @Param item: ConversationUiMessage = { @@ -63,83 +50,21 @@ export struct ChatMessageBubble { @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; - @Local expandedActivityPath: string = ''; - @Local typingPhase: number = 0; - private typingTimerId: number = 0; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); - aboutToAppear(): void { - if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { - return; - } - this.typingTimerId = setInterval(() => { - this.typingPhase = (this.typingPhase + 1) % 3; - }, 360); - } - - aboutToDisappear(): void { - if (this.typingTimerId !== 0) { - clearInterval(this.typingTimerId); - this.typingTimerId = 0; - } - } - build() { if (this.item.role === 'user') { - this.UserBubble() + ChatUserMessageBubble({ + item: this.item, + showRetryAction: this.showRetryAction, + onRetryMessage: this.onRetryMessage + }) } else { this.AssistantBubble() } } - @Builder - UserBubble() { - Row() { - Blank() - Column({ space: 6 }) { - if (this.visibleMessageText(this.item).length > 0 || (this.item.images && this.item.images.length > 0)) { - Column({ space: 8 }) { - if (this.item.images && this.item.images.length > 0) { - this.UserMessageImages(this.item.images) - } - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - } - } - .padding({ left: 10, right: 10, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(18) - .alignItems(HorizontalAlign.Start) - } - if (this.item.status === 'failed' && this.showRetryAction) { - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.sendFailed')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.text); - }) - } - } - } - .constraintSize({ maxWidth: '70%' }) - .alignItems(HorizontalAlign.End) - } - .width('100%') - .padding({ top: 8, bottom: 12 }) - } - @Builder AssistantBubble() { Row() { @@ -154,21 +79,11 @@ export struct ChatMessageBubble { } if (this.item.status === 'failed' && this.showRetryAction && (this.item.detail || '').trim().length > 0) { - Row({ space: 8 }) { - Text(RemoteI18n.t('generalChat.replyInterrupted')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.detail || '') - }) - } + ChatMessageRetryAction({ + assistant: true, + retryText: this.item.detail || '', + onRetry: this.onRetryMessage + }) } } .layoutWeight(1) @@ -203,7 +118,7 @@ export struct ChatMessageBubble { } } if (this.shouldShowTypingDots(item)) { - this.TypingDots() + ChatTypingDots() } if (item.tools && item.tools.length > 0) { this.Tools(item.tools) @@ -219,28 +134,6 @@ export struct ChatMessageBubble { } } - @Builder - AssistantAvatar() { - Row({ space: 4 }) { - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - } - .width(32) - .height(32) - .backgroundColor(ACCENT) - .borderRadius(16) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - @Builder StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { Column({ space: 10 }) { @@ -357,7 +250,7 @@ export struct ChatMessageBubble { } .width('100%') if (entry.tool && this.isRunningTool(entry.tool)) { - this.TypingDots() + ChatTypingDots() } if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) @@ -422,122 +315,38 @@ export struct ChatMessageBubble { }) } - @Builder - TypingDots() { - Row({ space: 5 }) { - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(0)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(1)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(2)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - } - .height(24) - .padding({ left: 2 }) - } - - private typingDotOpacity(index: number): number { - return this.typingPhase === index ? 1.0 : 0.34; - } - @Builder MessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage) => { - Image(image.data_url) - .width(92) - .height(92) - .objectFit(ImageFit.Cover) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .margin({ right: 8, bottom: 8 }) - }, (image: ConversationUiImage) => image.name) - } - .width('100%') - } - - @Builder - UserMessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage, index: number) => { - Image(image.data_url) - .width(112) - .height(112) - .objectFit(ImageFit.Cover) - .borderRadius(12) - .border({ width: 1, color: LINE }) - .margin({ right: index % 2 === 0 && images.length > 1 ? 8 : 0, bottom: index < images.length - 2 ? 8 : 0 }) - }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) - } - .width(images.length > 1 ? 232 : 112) + MessageImageGallery({ images }) } @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { - if (active) { - StreamingMarkdownContent({ - text, - active, - streamKey, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } else { - MarkdownContent({ - text, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } + MessageMarkdown({ + text, + active, + streamKey, + onCopyText: this.onCopyMessage, + onOpenLink: this.onOpenFilePreview + }) } @Builder FileCards(text: string) { - Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: MessageFileReference) => { - FileReferenceCard({ - path: file.path, - label: file.label, - status: this.fileStatus(file.path), - previewLabel: RemoteI18n.t('common.open'), - buttonLabel: this.fileButtonLabel(file.path), - disabled: this.downloadingFilePath === file.path, - selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - previewLoading: this.activeFilePreviewLoading && - FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - onPreview: (path: string, label: string) => { - this.onOpenFilePreview(path, label); - }, - onDownload: (path: string) => { - this.onDownloadFile(path); - } - }) - }, (file: MessageFileReference) => file.id) - } - .width('100%') + MessageFileCards({ + text, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + onPreview: this.onOpenFilePreview, + onDownload: this.onDownloadFile + }) + } + + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private visibleMessageText(item: ConversationUiMessage): string { @@ -896,56 +705,6 @@ export struct ChatMessageBubble { return ''; } - private activityGroupTitle(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const total = stats.thinkingCount + stats.readCount + stats.searchCount + stats.otherCount; - return `已折叠 ${total} 个思考和工具调用`; - } - - private activityGroupDetail(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const parts: string[] = []; - if (stats.thinkingCount > 0) { - parts.push(`思考 ${stats.thinkingCount}`); - } - if (stats.readCount > 0) { - parts.push(`读取 ${stats.readCount}`); - } - if (stats.searchCount > 0) { - parts.push(`搜索 ${stats.searchCount}`); - } - if (stats.otherCount > 0) { - parts.push(`其他 ${stats.otherCount}`); - } - return parts.join(' · '); - } - - private activityGroupStats(items: ConversationUiMessageItem[]): ActivityGroupStats { - const stats: ActivityGroupStats = { - thinkingCount: 0, - readCount: 0, - searchCount: 0, - otherCount: 0 - }; - items.forEach((entry: ConversationUiMessageItem) => { - if (this.isThinkingEntry(entry)) { - stats.thinkingCount += 1; - return; - } - if (entry.tool) { - const kind = this.activityToolKind(entry.tool); - if (kind === 'read') { - stats.readCount += 1; - } else if (kind === 'search') { - stats.searchCount += 1; - } else { - stats.otherCount += 1; - } - } - }); - return stats; - } - private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { const tools: ConversationUiToolStatus[] = []; group.items.forEach((entry: ConversationUiMessageItem) => { @@ -1141,13 +900,6 @@ export struct ChatMessageBubble { return ''; } - private hasRunningSubagentTask(item: ConversationUiMessage): boolean { - return (item.items || []).some((entry: ConversationUiMessageItem) => { - return !!entry.tool && this.normalizedToolName(entry.tool) === 'task' && - this.isRunningTool(entry.tool); - }); - } - private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; @@ -1217,30 +969,4 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): MessageFileReference[] { - return this.fileReferenceCache.referencesFor(text); - } - - private fileStatus(path: string): string { - if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && this.fileDownloadStatus.length > 0) { - return this.fileDownloadStatus; - } - if (path.indexOf('computer://') === 0) { - return RemoteI18n.t('chat.desktopFile'); - } - if (path.indexOf('file://') === 0) { - return RemoteI18n.t('chat.fileLink'); - } - return path; - } - - private fileButtonLabel(path: string): string { - if (this.downloadingFilePath === path) { - return RemoteI18n.t('chat.reading'); - } - if (this.downloadedFilePath === path) { - return RemoteI18n.t('common.done'); - } - return RemoteI18n.t('chat.download'); - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets new file mode 100644 index 0000000000..c485c0add8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -0,0 +1,109 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiMessage } from './ConversationUiModels'; +import { MessageImageGallery } from './ChatMessageContent'; +import { ACCENT, INK, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ChatTypingDots { + @Local phase: number = 0; + private timerId: number = 0; + + aboutToAppear(): void { + this.timerId = setInterval(() => { + this.phase = (this.phase + 1) % 3; + }, 360); + } + + aboutToDisappear(): void { + if (this.timerId !== 0) { + clearInterval(this.timerId); + this.timerId = 0; + } + } + + build() { + Row({ space: 5 }) { + ForEach([0, 1, 2], (index: number) => { + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.phase === index ? 1.0 : 0.34) + .animation({ duration: 180, curve: Curve.EaseInOut }) + }) + } + .height(24) + .padding({ left: 2 }) + } +} + +@ComponentV2 +export struct ChatMessageRetryAction { + @Param assistant: boolean = false; + @Param retryText: string = ''; + @Event onRetry: (text: string) => void = (_text: string) => {}; + + build() { + Row({ space: 8 }) { + Text(this.assistant ? RemoteI18n.t('generalChat.replyInterrupted') : RemoteI18n.t('chat.sendFailed')) + .fontSize(12) + .fontColor(RED) + Text(RemoteI18n.t('common.retry')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .height(28) + .padding({ left: 10, right: 10 }) + .backgroundColor(ACCENT) + .borderRadius(14) + .onClick(() => this.onRetry(this.retryText)) + } + } +} + +@ComponentV2 +export struct ChatUserMessageBubble { + @Param item: ConversationUiMessage = { + id: '', + role: 'user', + text: '', + status: '', + detail: '' + }; + @Param showRetryAction: boolean = false; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + + build() { + Row() { + Blank() + Column({ space: 6 }) { + if (this.visibleText().length > 0 || (this.item.images && this.item.images.length > 0)) { + Column({ space: 8 }) { + if (this.item.images && this.item.images.length > 0) { + MessageImageGallery({ images: this.item.images, userStyle: true }) + } + if (this.visibleText().length > 0) { + Text(this.visibleText()).fontSize(14).lineHeight(20).fontColor(INK) + } + } + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(18) + .alignItems(HorizontalAlign.Start) + } + if (this.item.status === 'failed' && this.showRetryAction) { + ChatMessageRetryAction({ retryText: this.item.text, onRetry: this.onRetryMessage }) + } + } + .constraintSize({ maxWidth: '70%' }) + .alignItems(HorizontalAlign.End) + } + .width('100%') + .padding({ top: 8, bottom: 12 }) + } + + private visibleText(): string { + const text = this.item.text.trim(); + return text === '(空消息)' || text === '(empty message)' ? '' : text; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets new file mode 100644 index 0000000000..10df9e3317 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -0,0 +1,111 @@ +import { ConversationUiImage } from './ConversationUiModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; +import { FileReferenceCard } from './FileReferenceCard'; +import { MarkdownContent } from './MarkdownContent'; +import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { LINE } from './Theme'; + +@ComponentV2 +export struct MessageImageGallery { + @Param images: ConversationUiImage[] = []; + @Param userStyle: boolean = false; + + build() { + Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { + ForEach(this.images, (image: ConversationUiImage, index: number) => { + Image(image.data_url) + .width(this.userStyle ? 112 : 92) + .height(this.userStyle ? 112 : 92) + .objectFit(ImageFit.Cover) + .borderRadius(this.userStyle ? 12 : 14) + .border({ width: 1, color: LINE }) + .margin({ + right: this.userStyle ? (index % 2 === 0 && this.images.length > 1 ? 8 : 0) : 8, + bottom: this.userStyle ? (index < this.images.length - 2 ? 8 : 0) : 8 + }) + }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) + } + .width(this.userStyle ? (this.images.length > 1 ? 232 : 112) : '100%') + } +} + +@ComponentV2 +export struct MessageMarkdown { + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = + (_reference: string, _label: string) => {}; + + build() { + if (this.active) { + StreamingMarkdownContent({ + text: this.text, + active: this.active, + streamKey: this.streamKey, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } else { + MarkdownContent({ + text: this.text, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } + } +} + +@ComponentV2 +export struct MessageFileCards { + @Param text: string = ''; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + private readonly cache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); + + build() { + Column({ space: 8 }) { + ForEach(this.cache.referencesFor(this.text), (file: MessageFileReference) => { + FileReferenceCard({ + path: file.path, + label: file.label, + status: this.fileStatus(file.path), + previewLabel: RemoteI18n.t('common.open'), + buttonLabel: this.fileButtonLabel(file.path), + disabled: this.downloadingFilePath === file.path, + selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + previewLoading: this.activeFilePreviewLoading && + FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + onPreview: this.onPreview, + onDownload: this.onDownload + }) + }, (file: MessageFileReference) => file.id) + } + .width('100%') + } + + private fileStatus(path: string): string { + if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && + this.fileDownloadStatus.length > 0) return this.fileDownloadStatus; + if (path.indexOf('computer://') === 0) return RemoteI18n.t('chat.desktopFile'); + if (path.indexOf('file://') === 0) return RemoteI18n.t('chat.fileLink'); + return path; + } + + private fileButtonLabel(path: string): string { + if (this.downloadingFilePath === path) return RemoteI18n.t('chat.reading'); + if (this.downloadedFilePath === path) return RemoteI18n.t('common.done'); + return RemoteI18n.t('chat.download'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets index e61d366e2e..aa21d5e3f4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets @@ -1,13 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ChatStatusBar { - @Prop title: string = ''; - @Prop detail: string = ''; - @Prop color: ResourceColor = MUTED; - @Prop canStop: boolean = false; - onStop: () => void = () => {}; + @Param title: string = ''; + @Param detail: string = ''; + @Param color: ResourceColor = MUTED; + @Param canStop: boolean = false; + @Event onStop: () => void = () => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 8f2d8f4ba5..2c0aceb715 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -7,7 +7,7 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from './ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; export enum ComposerPresentation { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets new file mode 100644 index 0000000000..e1e87a723d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets @@ -0,0 +1,245 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ConnectAccountDevicePage { + @Param deviceId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Event onBack: () => void = () => {}; + @Event onOpenScanner: () => void = () => {}; + @Event cloudListDevices: () => Promise = + async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local switchingDeviceId: string = ''; + @Local otherConnectionMethodsExpanded: boolean = false; + + aboutToAppear(): void { + this.refreshAccountDevices(); + } + + build() { + Column() { + Row({ space: 16 }) { + Stack() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23).fontColor([INK]).width(26).height(26) + } + .width(48).height(48).backgroundColor(SOFT).borderRadius(24) + .onClick(() => this.onBack()) + Column({ space: 4 }) { + Text(RemoteI18n.t('connect.accountDevicesTitle')) + .fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK).width('100%') + Text(RemoteI18n.t('connect.accountDevicesSubtitle')) + .fontSize(13).fontColor(MUTED).width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(92) + .padding({ left: 28, right: 28, top: 18 }) + .alignItems(VerticalAlign.Top) + + Scroll() { + Column({ space: 18 }) { + Text(RemoteI18n.t('connect.accountDevicesBody')) + .fontSize(14).lineHeight(21).fontColor(MUTED).width('100%') + this.AccountDeviceList() + this.OtherConnectionMethods() + } + .width('100%') + .constraintSize({ minHeight: '100%' }) + .padding({ left: 28, right: 28, top: 10, bottom: 34 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private AccountDeviceList() { + Column({ space: 4 }) { + Row() { + Text(RemoteI18n.t('connect.availableDevices')) + .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : + (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) + .fontSize(14) + .fontColor(this.accountDevicesBusy ? MUTED : + (this.accountDevicesError.length > 0 ? RED : ACCENT)) + .onClick(async () => { await this.refreshAccountDevices(); }) + } + .width('100%').height(38) + + if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Column() { + this.AccountDeviceSkeletonRow() + this.AccountDeviceSkeletonRow() + } + .width('100%').height(120) + } else if (this.desktopDevices().length === 0) { + Row() { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') + } + .width('100%').height(120).alignItems(VerticalAlign.Center) + } else { + Scroll() { + Column() { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountConnectDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + .width('100%') + } + .width('100%').height(120).scrollBar(BarState.Off) + } + } + .width('100%').height(174) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private OtherConnectionMethods() { + Column() { + Row({ space: 12 }) { + Text(RemoteI18n.t('connect.otherConnectionMethods')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) + Blank() + SymbolGlyph(this.otherConnectionMethodsExpanded ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13).fontColor([MUTED]) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => { + this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; + }) + + if (this.otherConnectionMethodsExpanded) { + Divider().color(LINE).margin({ left: 16, right: 16 }) + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20).fontColor([MUTED]).width(22).height(22).opacity(0.66) + Text(RemoteI18n.t('connect.scanPairCodeAction')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => this.onOpenScanner()) + } + } + .width('100%').backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private AccountDeviceSkeletonRow() { + Row({ space: 12 }) { + Text('').width(26).height(22).backgroundColor(SOFT).borderRadius(5) + Column({ space: 7 }) { + Text('').width('58%').height(12).backgroundColor(SOFT).borderRadius(4) + Text('').width(52).height(9).backgroundColor(SOFT).borderRadius(4) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(60).padding({ left: 4, right: 4 }).alignItems(VerticalAlign.Center) + } + + @Builder + private AccountConnectDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) + Column({ space: 3 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountDeviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (device.online) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + } + .width('100%').height(60).padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) + .onClick(async () => { + if (!this.canSelectAccountDevice(device)) return; + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + await this.cloudSelectDevice(device); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + }) + } + + private async refreshAccountDevices(): Promise { + if (this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + if (!this.hasOnlineDesktopDevice()) { + this.otherConnectionMethodsExpanded = true; + } + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + private canSelectAccountDevice(device: CloudAccountDevice): boolean { + return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; + } + + private hasOnlineDesktopDevice(): boolean { + const devices = this.desktopDevices(); + for (let index = 0; index < devices.length; index += 1) { + if (devices[index].online) return true; + } + return false; + } + + private accountDeviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.switchingDeviceId) { + return RemoteI18n.t('remote.settings.deviceConnecting'); + } + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets new file mode 100644 index 0000000000..e1dd2ccf16 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets @@ -0,0 +1,107 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; + +@ComponentV2 +export struct ConnectManualPairingOverlay { + @Param remoteUrl: string = ''; + @Param userIdInput: string = ''; + @Param password: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param canSubmit: boolean = false; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onPasswordChange: (value: string) => void = (_value: string) => {}; + @Event onCancel: () => void = () => {}; + @Event onSubmit: () => void = () => {}; + + build() { + Stack() { + Text('') + .width('100%') + .height('100%') + .backgroundColor(MODAL_SCRIM) + .onClick(this.onCancel) + + Column({ space: 20 }) { + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) + .fontSize(17) + .lineHeight(24) + .fontColor(MUTED) + .width('100%') + TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) + .height(62) + .fontSize(20) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(31) + .padding({ left: 20, right: 20 }) + .defaultFocus(true) + .onChange(this.onRemoteUrlChange) + if (this.requiresAccountAuth) { + TextInput({ + placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), + text: this.userIdInput + }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .onChange(this.onUserIdChange) + TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .type(InputType.Password) + .onChange(this.onPasswordChange) + Text(RemoteI18n.t('connect.accountPairBody')) + .fontSize(13) + .lineHeight(18) + .fontColor(MUTED) + .width('100%') + } + Row({ space: 12 }) { + Button(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(29) + .onClick(this.onCancel) + Button(RemoteI18n.t('connect.pair')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(this.canSubmit ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canSubmit ? PRIMARY_ACTION : SOFT) + .borderRadius(29) + .enabled(this.canSubmit) + .onClick(this.onSubmit) + } + .width('100%') + } + .width('82%') + .constraintSize({ maxWidth: 520 }) + .padding({ left: 28, right: 28, top: 30, bottom: 28 }) + .backgroundColor(CARD) + .borderRadius(34) + .border({ width: 1, color: LINE }) + } + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 8b917cecdd..b01a2402db 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -2,64 +2,52 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; +import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, - CONNECT_HERO_SURFACE, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -const CONNECT_SCAN_YELLOW: string = '#FFD021'; -const CONNECT_OVERLAY: string = '#99000000'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; -@Component +@ComponentV2 export struct ConnectView { private readonly scannerController: XComponentController = new XComponentController(); private scannerStarted: boolean = false; private scanCompleted: boolean = false; private scanStartRetryCount: number = 0; - @Prop remoteUrl: string = ''; - @Prop userId: string = ''; - @Prop showRemoteUrlInput: boolean = false; - @Prop statusText: string = RemoteI18n.t('status.waitingConnection'); - @Prop connectionState: string = 'idle'; - @Prop connectionFailureKind: string = ''; - @Prop isBusy: boolean = false; - @Prop isConnected: boolean = false; - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop deviceId: string = ''; - @Prop accountUserId: string = ''; - @Prop controlTargetDeviceId: string = ''; - @Prop requiresAccountAuth: boolean = false; - @Prop accountUsername: string = ''; - @Prop startWithScanner: boolean = true; - onBack: () => void = () => {}; - onConnect: (password?: string) => void = (_password?: string) => {}; - onClearPairing: () => void = () => {}; - onRemoteUrlChange: (value: string) => void = (_value: string) => {}; - onUserIdChange: (value: string) => void = (_value: string) => {}; - onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; - onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; - onPasteRemoteUrl: () => void = () => {}; - onScanRemoteUrl: () => void = () => {}; - cloudListDevices: () => Promise = async (): Promise => []; - cloudSelectDevice: (device: CloudAccountDevice) => Promise = + @Param remoteUrl: string = ''; + @Param userId: string = ''; + @Param statusText: string = RemoteI18n.t('status.waitingConnection'); + @Param connectionState: string = 'idle'; + @Param connectionFailureKind: string = ''; + @Param isBusy: boolean = false; + @Param isConnected: boolean = false; + @Param desktopName: string = ''; + @Param deviceId: string = ''; + @Param accountUserId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param accountUsername: string = ''; + @Param startWithScanner: boolean = true; + @Event onBack: () => void = () => {}; + @Event onConnect: (password?: string) => void = (_password?: string) => {}; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; + @Event onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; - @State showHelp: boolean = false; - @State pairingStep: string = 'intro'; - @State showManualPairing: boolean = false; - @State inlineScanError: string = ''; - @State accountPassword: string = ''; - @State cameraPermissionReady: boolean = false; - @State requestingCameraPermission: boolean = false; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State switchingDeviceId: string = ''; - @State otherConnectionMethodsExpanded: boolean = false; + @Local pairingStep: string = 'intro'; + @Local showManualPairing: boolean = false; + @Local inlineScanError: string = ''; + @Local accountPassword: string = ''; + @Local cameraPermissionReady: boolean = false; + @Local requestingCameraPermission: boolean = false; aboutToAppear(): void { if (this.isAccountAuthenticated()) { this.pairingStep = 'account'; - this.refreshAccountDevices(); } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { this.pairingStep = 'scan'; } @@ -89,251 +77,17 @@ export struct ConnectView { @Builder AccountDeviceSelectionPage() { - Column() { - Row({ space: 16 }) { - Stack() { - this.BackGlyph() - } - .width(48) - .height(48) - .backgroundColor(SOFT) - .borderRadius(24) - .onClick(() => { - this.stopInlineScan(); - this.onBack(); - }) - Column({ space: 4 }) { - Text(RemoteI18n.t('connect.accountDevicesTitle')) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.accountDevicesSubtitle')) - .fontSize(13) - .fontColor(MUTED) - .width('100%') - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(92) - .padding({ left: 28, right: 28, top: 18 }) - .alignItems(VerticalAlign.Top) - - Scroll() { - Column({ space: 18 }) { - Text(RemoteI18n.t('connect.accountDevicesBody')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .width('100%') - - this.AccountDeviceList() - this.OtherConnectionMethods() - } - .width('100%') - .constraintSize({ minHeight: '100%' }) - .padding({ left: 28, right: 28, top: 10, bottom: 34 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - AccountDeviceList() { - Column({ space: 4 }) { - Row() { - Text(RemoteI18n.t('connect.availableDevices')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Blank() - Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : - (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) - .fontSize(14) - .fontColor(this.accountDevicesBusy ? MUTED : - (this.accountDevicesError.length > 0 ? RED : ACCENT)) - .onClick(async () => { - await this.refreshAccountDevices(); - }) - } - .width('100%') - .height(38) - - if (this.accountDevicesBusy && this.accountDevices.length === 0) { - Column() { - this.AccountDeviceSkeletonRow() - this.AccountDeviceSkeletonRow() - } - .width('100%') - .height(120) - } else if (this.desktopDevices().length === 0) { - Row() { - Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) - .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') - } - .width('100%') - .height(120) - .alignItems(VerticalAlign.Center) - } else { - Scroll() { - Column() { - ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountConnectDeviceRow(device) - }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) - } - .width('100%') - } - .width('100%') - .height(120) - .scrollBar(BarState.Off) - } - - } - .width('100%') - .height(174) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - OtherConnectionMethods() { - Column() { - Row({ space: 12 }) { - Text(RemoteI18n.t('connect.otherConnectionMethods')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - if (this.otherConnectionMethodsExpanded) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(13) - .fontColor([MUTED]) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - } - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; - }) - - if (this.otherConnectionMethodsExpanded) { - Divider() - .color(LINE) - .margin({ left: 16, right: 16 }) - - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(20) - .fontColor([MUTED]) - .width(22) - .height(22) - .opacity(0.66) - Text(RemoteI18n.t('connect.scanPairCodeAction')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13) - .fontColor([MUTED]) - .width(16) - .height(16) - .opacity(0.44) - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.openScannerAfterPermission(); - }) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - AccountDeviceSkeletonRow() { - Row({ space: 12 }) { - Text('') - .width(26) - .height(22) - .backgroundColor(SOFT) - .borderRadius(5) - Column({ space: 7 }) { - Text('') - .width('58%') - .height(12) - .backgroundColor(SOFT) - .borderRadius(4) - Text('') - .width(52) - .height(9) - .backgroundColor(SOFT) - .borderRadius(4) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - } - - @Builder - AccountConnectDeviceRow(device: CloudAccountDevice) { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) - Column({ space: 3 }) { - Text(device.deviceName || device.deviceId) - .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.accountDeviceStatus(device)) - .fontSize(13).fontColor(device.online ? GREEN : MUTED) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - if (device.online) { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) - } - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) - .onClick(async () => { - if (!this.canSelectAccountDevice(device)) return; - this.switchingDeviceId = device.deviceId; - this.accountDevicesError = ''; - try { - await this.cloudSelectDevice(device); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } finally { - this.switchingDeviceId = ''; - } + ConnectAccountDevicePage({ + deviceId: this.deviceId, + controlTargetDeviceId: this.controlTargetDeviceId, + connectionState: this.connectionState, + cloudListDevices: this.cloudListDevices, + cloudSelectDevice: this.cloudSelectDevice, + onBack: () => { + this.stopInlineScan(); + this.onBack(); + }, + onOpenScanner: () => this.openScannerAfterPermission() }) } @@ -571,6 +325,27 @@ export struct ConnectView { .height(282) } + @Builder + ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { + Stack() { + Text('') + .width(42) + .height(4) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) + Text('') + .width(4) + .height(42) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) + } + .width(64) + .height(64) + .position({ x, y }) + } + @Builder PrimaryPairButton(text: string) { Button(text) @@ -622,373 +397,25 @@ export struct ConnectView { @Builder ManualPairingOverlay() { - Stack() { - Text('') - .width('100%') - .height('100%') - .backgroundColor(CONNECT_OVERLAY) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - - Column({ space: 20 }) { - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) - .fontSize(24) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) - .fontSize(17) - .lineHeight(24) - .fontColor(MUTED) - .width('100%') - TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) - .height(62) - .fontSize(20) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(31) - .padding({ left: 20, right: 20 }) - .defaultFocus(true) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - if (this.requiresAccountAuth) { - TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.accountPassword }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .type(InputType.Password) - .onChange((value: string) => { - this.accountPassword = value; - }) - Text(RemoteI18n.t('connect.accountPairBody')) - .fontSize(13) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - Row({ space: 12 }) { - Button(RemoteI18n.t('common.cancel')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(29) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - Button(RemoteI18n.t('connect.pair')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(29) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.stopInlineScan(); - this.showManualPairing = false; - this.onConnect(this.accountPassword); - }) - } - .width('100%') - } - .width('82%') - .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor(CARD) - .borderRadius(34) - .border({ width: 1, color: LINE }) - } - .width('100%') - .height('100%') - } - - @Builder - HelpCard() { - Column({ space: 6 }) { - Text(RemoteI18n.t('connect.stepsTitle')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.stepsBody')) - .fontSize(12) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - .padding(14) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .width('100%') - } - - @Builder - ScanCard() { - Column({ space: 12 }) { - Row() { - Blank() - Stack() { - Text('') - .width(72) - .height(72) - .borderRadius(22) - .backgroundColor(SOFT) - this.ScanCorner(12, 12, true, true) - this.ScanCorner(32, 12, false, true) - this.ScanCorner(12, 32, true, false) - this.ScanCorner(32, 32, false, false) - } - .width(72) - .height(72) - Blank() - } - .width('100%') - .height(92) - - Text(RemoteI18n.t('connect.scanTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.scanBody')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .width('100%') - .textAlign(TextAlign.Center) - } - .padding({ left: 18, right: 18, top: 26, bottom: 24 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onScanRemoteUrl(); - }) - } - - @Builder - ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { - Stack() { - Text('') - .width(42) - .height(4) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) - Text('') - .width(4) - .height(42) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) - } - .width(64) - .height(64) - .position({ x, y }) - } - - @Builder - RemoteUrlCard() { - Column({ space: 14 }) { - Row() { - Text(RemoteI18n.t('connect.userId')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(this.remoteUrl.trim().length > 0 ? RemoteI18n.t('connect.filled') : RemoteI18n.t('connect.remoteUrlShort')) - .fontSize(13) - .fontColor(MUTED) - .onClick(() => { - if (this.remoteUrl.trim().length > 0 || this.showRemoteUrlInput) { - this.onRemoteUrlInputVisibleChange(!this.showRemoteUrlInput); - } else { - this.onRemoteUrlInputVisibleChange(true); - this.onPasteRemoteUrl(); - } - }) - } - .width('100%') - - TextInput({ placeholder: RemoteI18n.t('connect.userPlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding({ left: 16, right: 16 }) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - - if (this.showRemoteUrlInput) { - TextInput({ placeholder: RemoteI18n.t('connect.urlPlaceholder'), text: this.remoteUrl }) - .height(50) - .fontSize(13) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - } - - Button(this.isBusy ? RemoteI18n.t('connect.connecting') : RemoteI18n.t('connect.connect')) - .width('100%') - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(14) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.onConnect(); - }) - } - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - } - - @Builder - StatusCard() { - Column({ space: 8 }) { - Text(RemoteI18n.t('connect.statusTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .margin({ bottom: 6 }) - this.DesktopStatus() - if (this.isConnectError() && this.failureHint().length > 0) { - Divider().color(LINE) - this.FailureHint() - } - } - .width('100%') - .padding(16) - .backgroundColor(CARD) - .borderRadius(16) - .border({ width: 1, color: LINE }) - } - - @Builder - FailureHint() { - Text(this.failureHint()) - .fontSize(12) - .lineHeight(18) - .fontColor(INK) - .width('100%') - .padding(12) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - } - - @Builder - DesktopStatus() { - List() { - ListItem() { - this.DesktopStatusContent() - } - .height(74) - .swipeAction(this.statusSwipeAction()) - } - .width('100%') - .height(74) - .scrollBar(BarState.Off) - .divider(null) - } - - @Builder - DesktopStatusContent() { - Row() { - Text('●') - .fontSize(12) - .fontColor(this.statusDotColor()) - Column({ space: 6 }) { - Text(this.statusTitle()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Text(this.statusDetail()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (this.remoteUrl.trim().length > 0) { - Text(this.desktopIdText()) - .fontSize(11) - .fontColor(SUBTLE) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - .margin({ left: 12 }) - if (this.isBusy) { - Blank() - Text('◌') - .fontSize(22) - .fontColor(INK) + ConnectManualPairingOverlay({ + remoteUrl: this.remoteUrl, + userIdInput: this.displayUserIdInput(), + password: this.accountPassword, + requiresAccountAuth: this.requiresAccountAuth, + canSubmit: this.canConnect(), + onRemoteUrlChange: this.onRemoteUrlChange, + onUserIdChange: this.onUserIdChange, + onPasswordChange: (value: string) => { this.accountPassword = value; }, + onCancel: () => this.closeManualPairing(), + onSubmit: () => { + this.ensureUserId(); + this.stopInlineScan(); + this.showManualPairing = false; + this.onConnect(this.accountPassword); } - } - .width('100%') - .height(74) - .backgroundColor(CARD) - .onClick(() => { - this.handleStatusClick(); }) } - @Builder - DeleteReveal() { - Text(RemoteI18n.t('connect.clear')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(84) - .height(74) - .backgroundColor(RED) - .onClick(() => { - this.onClearPairing(); - }) - } - private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; @@ -1002,13 +429,6 @@ export struct ConnectView { return SUBTLE; } - private statusTitle(): string { - if (this.remoteUrl.trim().length === 0) { - return RemoteI18n.t('connect.noDesktop'); - } - return this.desktopName || RemoteI18n.t('connect.targetDesktop'); - } - private statusDetail(): string { if (this.remoteUrl.trim().length === 0) { return RemoteI18n.t('connect.noDesktopDetail'); @@ -1028,13 +448,6 @@ export struct ConnectView { return RemoteI18n.t('connect.waitingDesktop'); } - private desktopIdText(): string { - if (this.desktopId.trim().length === 0) { - return RemoteI18n.t('connect.desktopIdUnavailable'); - } - return RemoteI18n.f('connect.desktopId', this.desktopId); - } - private displayUserIdInput(): string { if (this.requiresAccountAuth && this.accountUsername.length > 0 && this.userId.trim().length === 0) { return this.accountUsername; @@ -1045,24 +458,6 @@ export struct ConnectView { return this.userId; } - private statusSwipeAction(): SwipeActionOptions { - if (this.remoteUrl.trim().length === 0 || this.isBusy) { - return {}; - } - return { - end: { - builder: () => { - this.DeleteReveal(); - }, - actionAreaDistance: 84, - onAction: () => { - this.onClearPairing(); - } - }, - edgeEffect: SwipeEdgeEffect.None - }; - } - private handleStatusClick(): void { if (this.isBusy) { return; @@ -1093,6 +488,12 @@ export struct ConnectView { return this.displayUserIdInput().trim().length > 0 && this.accountPassword.length > 0; } + private closeManualPairing(): void { + this.stopInlineScan(); + this.showManualPairing = false; + this.resumeInlineScan(); + } + private currentStep(): string { if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { return 'account'; @@ -1109,57 +510,6 @@ export struct ConnectView { return 'intro'; } - private async refreshAccountDevices(): Promise { - if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; - this.accountDevicesBusy = true; - this.accountDevicesError = ''; - try { - this.accountDevices = await this.cloudListDevices(); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceLoadFailed'); - } finally { - this.accountDevicesBusy = false; - if (!this.hasOnlineDesktopDevice()) { - this.otherConnectionMethodsExpanded = true; - } - } - } - - private desktopDevices(): CloudAccountDevice[] { - return this.accountDevices.filter((device: CloudAccountDevice): boolean => - device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); - } - - private canSelectAccountDevice(device: CloudAccountDevice): boolean { - return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; - } - - private hasOnlineDesktopDevice(): boolean { - const devices = this.desktopDevices(); - for (let index = 0; index < devices.length; index += 1) { - if (devices[index].online) { - return true; - } - } - return false; - } - - private accountDeviceStatus(device: CloudAccountDevice): string { - if (device.deviceId === this.switchingDeviceId) { - return RemoteI18n.t('remote.settings.deviceConnecting'); - } - const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : - RemoteI18n.t('remote.settings.deviceOffline'); - if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { - return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; - } - if (device.deviceId === this.controlTargetDeviceId) { - return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; - } - return presence; - } - private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets new file mode 100644 index 0000000000..b8c7593144 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets @@ -0,0 +1,58 @@ +import { LINE, SOFT } from './Theme'; + +@ComponentV2 +export struct ConversationLoadingState { + @Param maxContentWidth: number = 0; + + build() { + Row() { + Column({ space: 18 }) { + this.AssistantSkeleton(78, '72%') + this.UserSkeleton(42, '46%') + this.AssistantSkeleton(112, '84%') + } + .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' }) + .padding({ left: 22, right: 22, top: 28, bottom: 28 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Top) + } + + @Builder + private AssistantSkeleton(height: number, width: string) { + Row() { + Column({ space: 9 }) { + Text('').width('74%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('92%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('58%').height(10).backgroundColor(LINE).borderRadius(5) + } + .width(width) + .height(height) + .padding({ left: 14, right: 14, top: 14, bottom: 14 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Start) + .backgroundColor(SOFT) + .borderRadius(10) + Blank().layoutWeight(1) + } + .width('100%') + .height(height) + } + + @Builder + private UserSkeleton(height: number, width: string) { + Row() { + Blank().layoutWeight(1) + Text('') + .width(width) + .height(height) + .backgroundColor(SOFT) + .borderRadius(10) + } + .width('100%') + .height(height) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets new file mode 100644 index 0000000000..6a82568798 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -0,0 +1,94 @@ +import { ConversationIntent } from '../actions/ConversationIntent'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationViewHost } from './ConversationViewHost'; +import { toConversationUiModelCatalog } from './ConversationUiModels'; +import { RemoteCreateSessionView } from './RemoteCreateSessionView'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { PAGE_BG } from './Theme'; + +@ComponentV2 +export struct ConversationRouteSurface { + @Param route: AppRoute = AppRoute.ChatHome; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Param useWidePresentation: boolean = false; + @Param contentHorizontalOffset: number = 0; + @Event onRestoreSidebar: () => void = () => {}; + + build() { + Column() { + if (this.route === AppRoute.RemoteHome) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.CompactHome, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onOpenSidebar: this.actions.onRemoteHome.openSidebar + }) + } else if (this.route === AppRoute.RemoteCreate) { + RemoteCreateSessionView({ + state: this.remoteCreateState, + presentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + isVoiceListening: this.remoteCreateState.isVoiceListening, + modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), + selectedModelId: this.remoteCreateState.selectedModelId, + showSidebarRestoreButton: this.showSidebarRestoreButton, + onRestoreSidebar: this.onRestoreSidebar, + onBack: this.actions.onRemoteCreate.back, + onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, + onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, + onSelectDevice: this.actions.onRemoteCreate.selectDevice, + onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), + onDraftChange: this.actions.onRemoteCreate.draftChanged, + onVoiceInput: this.actions.onRemoteCreate.voiceInput, + onSelectModel: this.actions.onRemoteCreate.selectModel, + onSend: this.actions.onRemoteCreate.send + }) + } else { + ConversationViewHost({ + viewState: ConversationViewState.project( + this.route, + this.remotePageState, + this.generalPageState, + this.actions.generalStatus() + ), + activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? + this.filePreviewState.target.remotePath : '', + activeFilePreviewLoading: this.route === AppRoute.RemoteChat && this.filePreviewState.visible && + this.filePreviewState.phase === FilePreviewPhase.Loading, + showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + contentHorizontalOffset: this.contentHorizontalOffset, + onRestoreSidebar: this.onRestoreSidebar, + onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) + }) + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets index f940c851e5..1c66b5a892 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets @@ -2,10 +2,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationSource } from '../navigation/AppRouteContract'; import { CARD, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ConversationSourceSwitcher { - @Prop activeSource: ConversationSource = ConversationSource.General; - onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Param activeSource: ConversationSource = ConversationSource.General; + @Event onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; build() { Row({ space: 2 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 6dbb9e2694..db3f0fc7f1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -6,6 +6,7 @@ import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './C import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; +import { ConversationLoadingState } from './ConversationLoadingState'; import { ConversationViewContract } from './ConversationViewContract'; import { ConversationUiModelCatalog, @@ -34,6 +35,7 @@ export struct ConversationView { @Param connectionState: string = 'connected'; @Param composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param isBusy: boolean = false; + @Param isLoadingConversation: boolean = false; @Param canStop: boolean = false; @Param hasMoreMessages: boolean = false; @Param timelineItems: ChatTimelineItem[] = []; @@ -110,7 +112,12 @@ export struct ConversationView { if (this.shouldShowStatusBar()) { this.ExecutionStatusBar() } - if (this.shouldShowSuggestions()) { + if (this.isLoadingConversation) { + ConversationLoadingState({ + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 + }) + .layoutWeight(1) + } else if (this.shouldShowSuggestions()) { Blank().layoutWeight(1) if (!this.isVoiceListening) { this.PromptArea() @@ -179,6 +186,7 @@ export struct ConversationView { workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, showBackButton: this.showBackButton, + showSidebarButton: this.showSidebarButton, showSidebarRestoreButton: this.showSidebarRestoreButton, showActionsMenu: this.showHeaderActions, actionsMenu: () => { @@ -187,6 +195,9 @@ export struct ConversationView { onBack: () => { this.onBack(); }, + onOpenSidebar: () => { + this.onOpenSidebar(); + }, onRestoreSidebar: () => { this.onRestoreSidebar(); }, @@ -224,7 +235,7 @@ export struct ConversationView { timelineItems: this.visibleTimelineItems(), timelineRevision: this.timelineRevision, hasMoreMessages: this.hasMoreMessages, - isBusy: this.isBusy, + isBusy: this.isBusy || this.isLoadingConversation, connectionState: this.connectionState, statusText: this.statusText, downloadingFilePath: this.downloadingFilePath, @@ -675,7 +686,7 @@ export struct ConversationView { } private shouldShowStatusBar(): boolean { - return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; + return !this.isLoadingConversation && this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } private connectionColor(): ResourceColor { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 1c6bae8410..045054b74a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -4,7 +4,7 @@ import { ConversationIntent, ConversationIntents, ConversationIntentType -} from './ConversationIntent'; +} from '../actions/ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @@ -32,6 +32,7 @@ export struct ConversationViewHost { connectionState: this.viewState.connectionState, composerCapabilities: this.viewState.composerCapabilities, isBusy: this.viewState.isBusy, + isLoadingConversation: this.viewState.isLoadingConversation, canStop: this.viewState.canStop, hasMoreMessages: this.viewState.hasMoreMessages, timelineItems: this.viewState.timelineItems, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index d965d5b08e..f3e7176df9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; @ComponentV2 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets index 7ad51ec5d1..a00d0ca784 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets @@ -1,17 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct CreateSessionSheet { - @Prop createAgentType: string = 'code'; - @Prop workspaceName: string = ''; - @Prop workspaceBranch: string = ''; - @Prop isBusy: boolean = false; - @Link sessionTitle: string; - @Link instruction: string; - onClose: () => void = () => {}; - onChooseWorkspace: () => void = () => {}; - onStart: () => void = () => {}; + @Param createAgentType: string = 'code'; + @Param workspaceName: string = ''; + @Param workspaceBranch: string = ''; + @Param isBusy: boolean = false; + @Param sessionTitle: string = ''; + @Param instruction: string = ''; + @Event onSessionTitleChange: (value: string) => void = (_value: string) => {}; + @Event onInstructionChange: (value: string) => void = (_value: string) => {}; + @Event onClose: () => void = () => {}; + @Event onChooseWorkspace: () => void = () => {}; + @Event onStart: () => void = () => {}; build() { Column() { @@ -123,7 +125,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.sessionTitle = value; + this.onSessionTitleChange(value); }) } .width('100%') @@ -146,7 +148,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.instruction = value; + this.onInstructionChange(value); }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index 72fb3c48cc..a4f939b8ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -1,8 +1,8 @@ import { MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct DefaultAccountAvatar { - @Prop avatarSize: number = 34; + @Param avatarSize: number = 34; build() { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets index 06f477b898..139edbac82 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets @@ -1,17 +1,17 @@ import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct FileReferenceCard { - @Prop path: string = ''; - @Prop label: string = ''; - @Prop status: string = ''; - @Prop previewLabel: string = ''; - @Prop buttonLabel: string = ''; - @Prop disabled: boolean = false; - @Prop selected: boolean = false; - @Prop previewLoading: boolean = false; - onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - onDownload: (path: string) => void = (_path: string) => {}; + @Param path: string = ''; + @Param label: string = ''; + @Param status: string = ''; + @Param previewLabel: string = ''; + @Param buttonLabel: string = ''; + @Param disabled: boolean = false; + @Param selected: boolean = false; + @Param previewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index a088f54d88..0382f2db61 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,11 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct GeneralChatHeader { @Param title: string = ''; + /** Secondary context line. Empty keeps the single-line header. */ + @Param subtitle: string = ''; @Param showActions: boolean = false; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = false; @@ -21,23 +23,42 @@ export struct GeneralChatHeader { build() { Row({ space: 8 }) { this.LeadingControl() + this.TitleBlock() + this.TrailingControl() + } + .width('100%') + .height(this.hasSubtitle() ? 76 : 64) + .alignItems(VerticalAlign.Center) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(PAGE_BG) + } + /** Mirrors the conversation header: title above a muted context line. */ + @Builder + private TitleBlock() { + Column({ space: 3 }) { Text(this.title || 'BitFun') - .fontSize(17) + .fontSize(this.hasSubtitle() ? 18 : 17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) - - this.TrailingControl() + if (this.hasSubtitle()) { + Text(this.subtitle) + .fontSize(14) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + } } - .width('100%') - .height(64) - .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(PAGE_BG) + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + private hasSubtitle(): boolean { + return this.subtitle.length > 0; } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 486aad6a67..5b7e8d76a8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -7,12 +7,12 @@ import { } from '../../services/MarkdownParser'; import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct MarkdownContent { private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); - @Prop text: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Param text: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { Column({ space: 5 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 3c92d19156..07996fbb89 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -4,24 +4,24 @@ import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels' import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/GeneralChatConfigStore'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct ModelServiceSettingsPanel { private readonly contentScroller: Scroller = new Scroller(); private focusScrollTimerId: number = 0; private blurResetTimerId: number = 0; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; - @Prop apiUrl: string = ''; - @Prop modelName: string = ''; - @Prop hasApiKey: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param apiUrl: string = ''; + @Param modelName: string = ''; + @Param hasApiKey: boolean = false; + @Param modelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - onClose: () => void = () => {}; - onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; - onTest: ( + @Param selectedModelId: string = ''; + @Event onClose: () => void = () => {}; + @Event onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; + @Event onTest: ( apiUrl: string, apiKey: string, modelName: string, @@ -32,7 +32,7 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - onSave: ( + @Event onSave: ( apiUrl: string, apiKey: string, modelName: string, @@ -43,16 +43,16 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - @State draftApiUrl: string = ''; - @State draftApiKey: string = ''; - @State draftModelName: string = ''; - @State clearApiKey: boolean = false; - @State isSaving: boolean = false; - @State isTesting: boolean = false; - @State feedbackText: string = ''; - @State feedbackIsError: boolean = false; - @State focusedFieldKind: string = ''; - @State showLocalEditor: boolean = false; + @Local draftApiUrl: string = ''; + @Local draftApiKey: string = ''; + @Local draftModelName: string = ''; + @Local clearApiKey: boolean = false; + @Local isSaving: boolean = false; + @Local isTesting: boolean = false; + @Local feedbackText: string = ''; + @Local feedbackIsError: boolean = false; + @Local focusedFieldKind: string = ''; + @Local showLocalEditor: boolean = false; aboutToAppear(): void { this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 67b5b38130..539ea93aaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -14,10 +15,12 @@ export struct RemoteChatHeader { @Param workspaceBranch: string = ''; @Param desktopName: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; @Param showActionsMenu: boolean = false; @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; + @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onOpenActions: () => void = () => {}; @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @@ -100,6 +103,13 @@ export struct RemoteChatHeader { .onClick(() => { this.onBack(); }) + } else if (this.showSidebarButton) { + CompactMenuButton({ + controlSize: 44, + onOpen: () => { + this.onOpenSidebar(); + } + }) } else { Blank().width(44).height(44) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 9ed4245319..3396da0e91 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -5,45 +5,45 @@ import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -@Component +@ComponentV2 export struct RemoteControlSettingsSheet { - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop userId: string = ''; - @Prop accountUsername: string = ''; - @Prop @Watch('handleAccountUserChanged') accountUserId: string = ''; - @Prop deviceId: string = ''; - @Prop controlTargetType: string = 'none'; - @Prop controlTargetDeviceId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop statusText: string = ''; - @Prop isBusy: boolean = false; - @Prop openAccountOnAppear: boolean = false; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onAddConnection: () => void = () => {}; - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - cloudSync: () => Promise = async (): Promise => '0'; - cloudLogout: () => Promise = async (): Promise => {}; - cloudListDevices: () => Promise = async (): Promise => []; - getPermissionMode: () => Promise = async (): Promise => 'ask'; - setPermissionMode: (mode: RemotePermissionMode) => Promise = + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param userId: string = ''; + @Param accountUsername: string = ''; + @Param accountUserId: string = ''; + @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Param statusText: string = ''; + @Param isBusy: boolean = false; + @Param openAccountOnAppear: boolean = false; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onAddConnection: () => void = () => {}; + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudSync: () => Promise = async (): Promise => '0'; + @Event cloudLogout: () => Promise = async (): Promise => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; + @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; - onDisconnect: () => void = () => {}; - onReconnect: () => void = () => {}; - @State showProfile: boolean = false; - @State showLogin: boolean = false; - @State cloudSyncBusy: boolean = false; - @State cloudSyncStatus: string = ''; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State permissionMode: RemotePermissionMode = 'ask'; - @State permissionModeBusy: boolean = false; - @State permissionModeLoaded: boolean = false; - @State permissionModeError: string = ''; - @State confirmFullAccess: boolean = false; - @State logoutBusy: boolean = false; + @Event onDisconnect: () => void = () => {}; + @Event onReconnect: () => void = () => {}; + @Local showProfile: boolean = false; + @Local showLogin: boolean = false; + @Local cloudSyncBusy: boolean = false; + @Local cloudSyncStatus: string = ''; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local permissionMode: RemotePermissionMode = 'ask'; + @Local permissionModeBusy: boolean = false; + @Local permissionModeLoaded: boolean = false; + @Local permissionModeError: string = ''; + @Local confirmFullAccess: boolean = false; + @Local logoutBusy: boolean = false; aboutToAppear(): void { this.showProfile = this.openAccountOnAppear && this.isAccountAuthenticated(); @@ -855,6 +855,7 @@ export struct RemoteControlSettingsSheet { return this.accountUserId.trim().length > 0; } + @Monitor('accountUserId') private handleAccountUserChanged(): void { if (this.isAccountAuthenticated() && this.accountDevices.length === 0) { this.refreshAccountDevices(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index d1d6b2d8c5..bfca15020c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -3,9 +3,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; import { CARD, INK, MUTED, SOFT } from './Theme'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -46,12 +46,18 @@ export struct RemoteSessionList { @Local showSessionActionSheet: boolean = false; @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; + @Local optimisticSelectedSessionId: string = ''; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { this.createMenuPath = ''; } + @Monitor('selectedSessionId') + onSelectedSessionChanged(): void { + this.optimisticSelectedSessionId = ''; + } + build() { Column() { Scroll() { @@ -566,7 +572,7 @@ export struct RemoteSessionList { Text(item.title || RemoteI18n.t('sidebar.untitled')) .width('100%') .fontSize(15) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSessionSelected(item.id) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -587,9 +593,23 @@ export struct RemoteSessionList { .height(this.metadataText(item).length > 0 ? 56 : 46) .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .backgroundColor(this.isSessionSelected(item.id) ? SOFT : '#00000000') .borderRadius(10) + .onTouch((event: TouchEvent) => { + if (this.isBusy) { + return; + } + if (event.type === TouchType.Down) { + this.optimisticSelectedSessionId = item.id; + } else if (event.type === TouchType.Cancel) { + this.optimisticSelectedSessionId = ''; + } + }) .onClick(() => { + if (this.isBusy) { + return; + } + this.optimisticSelectedSessionId = item.id; this.onOpenSession(item); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) @@ -610,6 +630,12 @@ export struct RemoteSessionList { }) } + private isSessionSelected(sessionId: string): boolean { + const selectedSessionId = this.optimisticSelectedSessionId.length > 0 ? + this.optimisticSelectedSessionId : this.selectedSessionId; + return selectedSessionId === sessionId; + } + @Builder private SessionMoreButton(item: RemoteSession) { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index a90edecec2..0a3cb71b96 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -4,23 +4,23 @@ import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -@Component +@ComponentV2 export struct SettingsSheet { - @Prop generalChatApiUrl: string = ''; - @Prop generalChatModelName: string = ''; - @Prop hasGeneralChatApiKey: boolean = false; - @Prop generalChatModelCatalog: RemoteModelCatalog = { + @Param generalChatApiUrl: string = ''; + @Param generalChatModelName: string = ''; + @Param hasGeneralChatApiKey: boolean = false; + @Param generalChatModelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedGeneralChatModelId: string = ''; - @Prop accountUsername: string = ''; - @Prop authenticatedUserId: string = ''; - @Prop deviceId: string = ''; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onSaveGeneralChatConfig: ( + @Param selectedGeneralChatModelId: string = ''; + @Param accountUsername: string = ''; + @Param authenticatedUserId: string = ''; + @Param deviceId: string = ''; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onSaveGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -31,7 +31,7 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - onTestGeneralChatConfig: ( + @Event onTestGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -42,10 +42,10 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - @State showModelService: boolean = false; - @State savedGeneralChatApiUrl: string = ''; - @State savedGeneralChatModelName: string = ''; - @State savedGeneralChatHasApiKey: boolean = false; + @Local showModelService: boolean = false; + @Local savedGeneralChatApiUrl: string = ''; + @Local savedGeneralChatModelName: string = ''; + @Local savedGeneralChatHasApiKey: boolean = false; aboutToAppear(): void { this.showModelService = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets new file mode 100644 index 0000000000..af5a2504d6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -0,0 +1,151 @@ +import { CARD, GREEN, INK, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarGlyph { + @Param kind: string = ''; + @Param connectionState: string = ''; + + build() { + if (this.kind === 'session_more') { + this.MoreDots() + } else if (this.kind === 'remote') { + this.Remote() + } else if (this.kind === 'search') { + this.Search() + } else if (this.kind === 'notebook') { + this.Notebook() + } else if (this.kind === 'clock') { + this.Clock() + } else if (this.kind === 'apps') { + this.Apps() + } else if (this.kind === 'code_flower') { + this.CodeFlower() + } else if (this.kind === 'more') { + this.More() + } else if (this.kind === 'edit') { + this.Edit() + } else if (this.kind === 'settings') { + this.Settings() + } + } + + @Builder + private MoreDots() { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Remote() { + Stack({ alignContent: Alignment.Center }) { + if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { + Image($r('app.media.remote_ref_sidebar_connected')) + .width(35).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(INK) + Text('').width(8).height(8).backgroundColor(GREEN).borderRadius(4) + .position({ x: 24, y: 22 }) + } else { + Image($r('app.media.remote_logo')) + .width(34).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(MUTED) + } + } + .width(35).height(34) + } + + @Builder + private Search() { + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Notebook() { + Stack() { + Text('').width(22).height(24).borderRadius(5).border({ width: 1.5, color: INK }) + .position({ x: 8, y: 5 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 11 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Clock() { + Stack() { + Text('').width(26).height(26).borderRadius(13).border({ width: 1.5, color: INK }) + .position({ x: 4, y: 4 }) + Text('').width(1.5).height(9).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 10 }) + Text('').width(9).height(1.5).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Apps() { + Column({ space: 8 }) { + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + } + .width(24).height(24) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private AppDot() { + Text('').width(8).height(8).borderRadius(4).backgroundColor(INK) + } + + @Builder + private CodeFlower() { + Stack() { + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 1 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 17, y: 9 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 17 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 1, y: 9 }) + Text('').width(14).height(14).borderRadius(7).backgroundColor(CARD) + .position({ x: 10, y: 10 }) + } + .width(34).height(34) + } + + @Builder + private More() { + Row({ space: 5 }) { this.Dot(); this.Dot(); this.Dot(); } + .width(30).height(22) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Dot() { + Text('').width(5).height(5).borderRadius(3).backgroundColor(INK) + } + + @Builder + private Edit() { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Settings() { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index 227cccd2a2..283e2698a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -2,14 +2,14 @@ import { MarkdownContent } from './MarkdownContent'; const STREAMING_MARKDOWN_CACHE: Map = new Map(); -@Component +@ComponentV2 export struct StreamingMarkdownContent { - @Prop @Watch('handleTextChanged') text: string = ''; - @Prop @Watch('handleTextChanged') active: boolean = false; - @Prop @Watch('handleTextChanged') streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; - @State renderedText: string = ''; + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Local renderedText: string = ''; private targetText: string = ''; private timerId: number = 0; private frameIntervalMs: number = 40; @@ -42,6 +42,7 @@ export struct StreamingMarkdownContent { }) } + @Monitor('text', 'active', 'streamKey') private handleTextChanged(): void { if (!this.active) { this.clearTimer(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 6adc633b73..854c234a46 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -12,6 +12,8 @@ export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); export const CONNECT_HERO_SURFACE: ResourceColor = $r('app.color.connect_hero_surface'); +export const CONNECT_SCAN_ACCENT: ResourceColor = $r('app.color.connect_scan_accent'); +export const MODAL_SCRIM: ResourceColor = $r('app.color.modal_scrim'); export const SOFT: ResourceColor = $r('app.color.soft'); export const FLOATING_PANEL_BG: ResourceColor = $r('app.color.floating_panel_bg'); export const GREEN: ResourceColor = $r('app.color.green'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 89bacf2425..42a9a75af5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,14 +1,14 @@ import { MUTED } from './Theme'; -@Component +@ComponentV2 export struct ThinkingBlock { - @Prop text: string = ''; - @Prop status: string = ''; - @Prop keepExpandedWhenDone: boolean = false; - @Prop streaming: boolean = false; - @Prop streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - @State dotPhase: number = 0; + @Param text: string = ''; + @Param status: string = ''; + @Param keepExpandedWhenDone: boolean = false; + @Param streaming: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Local dotPhase: number = 0; private dotTimerId: number = 0; aboutToAppear(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets new file mode 100644 index 0000000000..cc029ab416 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets @@ -0,0 +1,40 @@ +import { MUTED } from './Theme'; + +@ComponentV2 +export struct ToolGlyph { + @Param kind: string = 'tool'; + @Param color: ResourceColor = MUTED; + + build() { + SymbolGlyph(this.symbol()) + .fontSize(this.isChevron() ? 18 : 14) + .fontColor([this.color]) + .width(this.isChevron() ? 14 : 15) + .height(this.isChevron() ? 14 : 15) + } + + private isChevron(): boolean { + return this.kind.indexOf('chevron_') === 0; + } + + private symbol(): Resource { + if (this.kind === 'search') return $r('sys.symbol.magnifyingglass'); + if (this.kind === 'document') return $r('sys.symbol.doc_text'); + if (this.kind === 'stack') return $r('sys.symbol.rectangle_stack'); + if (this.kind === 'question') return $r('sys.symbol.questionmark_circle'); + if (this.kind === 'todo') return $r('sys.symbol.list_checkmark'); + if (this.kind === 'task') return $r('sys.symbol.robot'); + if (this.kind === 'git') return $r('sys.symbol.arrow_triangle_merge'); + if (this.kind === 'delete') return $r('sys.symbol.trash'); + if (this.kind === 'diff') return $r('sys.symbol.doc_text_badge_magnifyingglass'); + if (this.kind === 'patch' || this.kind === 'command') return $r('sys.symbol.code_square'); + if (this.kind === 'create') return $r('sys.symbol.doc_text_badge_arrow_up'); + if (this.kind === 'mutate') return $r('sys.symbol.square_and_pencil'); + if (this.kind === 'folder') return $r('sys.symbol.folder'); + if (this.kind === 'web') return $r('sys.symbol.link'); + if (this.kind === 'chevron_right') return $r('sys.symbol.chevron_right'); + if (this.kind === 'chevron_up') return $r('sys.symbol.chevron_up'); + if (this.kind === 'chevron_down') return $r('sys.symbol.chevron_down'); + return $r('sys.symbol.wrench_and_screwdriver'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets new file mode 100644 index 0000000000..9af587e2af --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -0,0 +1,171 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ToolConfirmationPanel { + @Param toolId: string = ''; + @Param defaultInputText: string = ''; + @Param hasEditableInput: boolean = false; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = + (_toolId: string, _updatedInput?: Object) => {}; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Local inputText: string = ''; + @Local inputError: string = ''; + + aboutToAppear(): void { + this.inputText = this.defaultInputText; + } + + build() { + Column({ space: 8 }) { + if (this.hasEditableInput) { + this.InputEditor() + } + Row({ space: 8 }) { + Text(RemoteI18n.t('chat.approve')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(ACCENT) + .borderRadius(16) + .onClick(() => this.approve()) + Text(RemoteI18n.t('chat.reject')) + .fontSize(12) + .fontColor(INK) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(SOFT) + .borderRadius(16) + .border({ width: 1, color: LINE }) + .onClick(() => this.onRejectTool(this.toolId)) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + @Builder + private InputEditor() { + Column({ space: 6 }) { + Row() { + Text(RemoteI18n.t('chat.toolInput')).fontSize(11).fontColor(MUTED) + Blank() + Text(RemoteI18n.t('chat.reset')) + .fontSize(11) + .fontColor(MUTED) + .onClick(() => { + this.inputText = this.defaultInputText; + this.inputError = ''; + }) + } + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.inputText }) + .height(96) + .fontSize(12) + .fontColor(INK) + .lineHeight(17) + .backgroundColor(SOFT) + .borderRadius(14) + .padding(10) + .border({ width: 1, color: this.inputError.length > 0 ? RED : LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { + this.inputText = value; + this.inputError = ''; + }) + if (this.inputError.length > 0) { + Text(this.inputError).fontSize(11).fontColor(RED) + } + } + .width('100%') + } + + private approve(): void { + if (this.toolId.length === 0) { + return; + } + if (!this.hasEditableInput) { + this.onApproveTool(this.toolId); + return; + } + const rawInput = this.inputText.trim(); + if (rawInput.length === 0) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + try { + const parsed = JSON.parse(rawInput) as Object; + if (parsed === null || Array.isArray(parsed)) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + this.inputError = ''; + this.onApproveTool(this.toolId, parsed); + } catch (_err) { + this.inputError = RemoteI18n.t('chat.jsonInvalid'); + } + } +} + +@ComponentV2 +export struct ToolQuestionAnswerPanel { + @Param toolId: string = ''; + @Param prompt: string = ''; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Local answerText: string = ''; + + build() { + Column({ space: 8 }) { + Text(this.prompt) + .fontSize(12) + .lineHeight(17) + .fontColor(INK) + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerText }) + .height(78) + .fontSize(13) + .backgroundColor(CARD) + .borderRadius(14) + .padding(12) + .border({ width: 1, color: LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { this.answerText = value; }) + Row() { + Text(RemoteI18n.t('chat.submitAnswer')) + .fontSize(12) + .fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(this.canSubmit() ? ACCENT : SOFT) + .borderRadius(16) + .onClick(() => this.submit()) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + private canSubmit(): boolean { + return this.toolId.length > 0 && this.answerText.trim().length > 0; + } + + private submit(): void { + if (!this.canSubmit()) { + return; + } + const answer = this.answerText.trim(); + const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; + this.onAnswerQuestion(this.toolId, answers); + this.answerText = ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index bd1316fb2c..bb8c2ed7e9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,7 +1,9 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; -import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ToolGlyph } from './ToolGlyphs'; +import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractionPanels'; interface QuestionPreview { header?: string; @@ -61,11 +63,6 @@ export struct ToolStatusList { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - @Local questionAnswerToolId: string = ''; - @Local questionAnswerText: string = ''; - @Local toolInputEditToolId: string = ''; - @Local toolInputEditText: string = ''; - @Local toolInputEditError: string = ''; @Local expanded: boolean = false; @Local expandedToolKey: string = ''; @@ -138,39 +135,20 @@ export struct ToolStatusList { } if (this.isPendingConfirmation(tool)) { - if (this.hasEditableToolInput(tool)) { - this.ToolInputEditor(tool) - } - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.approve')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(ACCENT) - .borderRadius(16) - .onClick(() => { - this.approveToolWithInput(tool); - }) - Text(RemoteI18n.t('chat.reject')) - .fontSize(12) - .fontColor(INK) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(SOFT) - .borderRadius(16) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onRejectTool(tool.id || ''); - }) - } - .width('100%') - .padding({ left: 30 }) + ToolConfirmationPanel({ + toolId: tool.id || '', + defaultInputText: this.defaultToolInputText(tool), + hasEditableInput: this.hasEditableToolInput(tool), + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool + }) } if (this.isQuestionTool(tool)) { - this.QuestionAnswer(tool) + ToolQuestionAnswerPanel({ + toolId: tool.id || '', + prompt: this.questionPrompt(tool), + onAnswerQuestion: this.onAnswerQuestion + }) } if (this.isRunningTool(tool)) { Row() { @@ -260,122 +238,12 @@ export struct ToolStatusList { @Builder SummaryTypeSymbol(entry: ToolRenderEntry) { - if (entry.searchCount > 0 && entry.readCount === 0) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else if (entry.readCount > 0 && entry.searchCount === 0) { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.rectangle_stack')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.summaryGlyphKind(entry), color: this.summaryTypeColor(entry) }) } @Builder ToolTypeSymbol(tool: ConversationUiToolStatus) { - if (this.isQuestionLikeTool(tool)) { - SymbolGlyph($r('sys.symbol.questionmark_circle')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTodoTool(tool)) { - SymbolGlyph($r('sys.symbol.list_checkmark')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTaskTool(tool)) { - SymbolGlyph($r('sys.symbol.robot')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isGitTool(tool)) { - SymbolGlyph($r('sys.symbol.arrow_triangle_merge')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDeleteTool(tool)) { - SymbolGlyph($r('sys.symbol.trash')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDiffTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isPatchTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileCreateTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_arrow_up')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileMutationTool(tool)) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileReadTool(tool)) { - if (this.isDirectoryListTool(tool)) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } - } else if (this.isSearchTool(tool)) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isWebTool(tool)) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isCommandTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.toolGlyphKind(tool), color: this.toolTypeColor(tool) }) } @Builder @@ -392,77 +260,9 @@ export struct ToolStatusList { .border({ width: 1, color: CARD }) } - @Builder - RunningDotsIcon() { - Row({ space: 2 }) { - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - } - .width(16) - .height(16) - .justifyContent(FlexAlign.Center) - } - - @Builder - AlertCircleIcon(color: string, mark: string) { - Text(mark) - .width(16) - .height(16) - .fontSize(10) - .fontColor(color) - .textAlign(TextAlign.Center) - .border({ width: 1.5, color }) - .borderRadius(8) - } - - @Builder - NeutralDotIcon() { - Stack() { - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(MUTED) - .position({ x: 6, y: 6 }) - } - .width(16) - .height(16) - } - @Builder ChevronIcon(direction: string) { - if (direction === 'right') { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else if (direction === 'up') { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } + ToolGlyph({ kind: `chevron_${direction}`, color: MUTED }) } @Builder @@ -483,99 +283,6 @@ export struct ToolStatusList { .padding({ left: 30, top: 2 }) } - @Builder - ToolInputEditor(tool: ConversationUiToolStatus) { - Column({ space: 6 }) { - Row() { - Text(RemoteI18n.t('chat.toolInput')) - .fontSize(11) - .fontColor(MUTED) - Blank() - Text(RemoteI18n.t('chat.reset')) - .fontSize(11) - .fontColor(MUTED) - .onClick(() => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = this.defaultToolInputText(tool); - this.toolInputEditError = ''; - }) - } - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.toolInputTextForTool(tool) }) - .height(96) - .fontSize(12) - .fontColor(INK) - .lineHeight(17) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(10) - .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = value; - this.toolInputEditError = ''; - }) - if (this.toolInputErrorForTool(tool.id || '').length > 0) { - Text(this.toolInputErrorForTool(tool.id || '')) - .fontSize(11) - .fontColor(RED) - } - } - .width('100%') - .padding({ left: 30 }) - } - - @Builder - QuestionAnswer(tool: ConversationUiToolStatus) { - Column({ space: 8 }) { - Text(this.questionPrompt(tool)) - .fontSize(12) - .lineHeight(17) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerTextForTool(tool.id || '') }) - .height(78) - .fontSize(13) - .backgroundColor(CARD) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.questionAnswerToolId = tool.id || ''; - this.questionAnswerText = value; - }) - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.submitAnswer')) - .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) - .borderRadius(16) - .onClick(() => { - if (this.canSubmitQuestion(tool.id || '')) { - const answer = this.questionAnswerText.trim(); - const answers: ConversationUiQuestionAnswer = { - answer, - '0': answer - }; - this.onAnswerQuestion(tool.id || '', answers); - this.questionAnswerText = ''; - this.questionAnswerToolId = ''; - } - }) - } - .width('100%') - } - .width('100%') - .padding({ left: 30 }) - } - private displayStatus(status: string): string { const normalized = (status || '').toLowerCase(); if (normalized === 'running' || normalized === 'active') { @@ -758,18 +465,6 @@ export struct ToolStatusList { }; } - private hasCollapsibleTools(): boolean { - let runLength = 0; - return this.tools.some((tool: ConversationUiToolStatus) => { - if (this.shouldCollapseExploreTool(tool)) { - runLength += 1; - return runLength >= 2; - } - runLength = 0; - return false; - }); - } - private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool)) { @@ -823,6 +518,29 @@ export struct ToolStatusList { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } + private summaryGlyphKind(entry: ToolRenderEntry): string { + if (entry.searchCount > 0 && entry.readCount === 0) return 'search'; + if (entry.readCount > 0 && entry.searchCount === 0) return 'document'; + return 'stack'; + } + + private toolGlyphKind(tool: ConversationUiToolStatus): string { + if (this.isQuestionLikeTool(tool)) return 'question'; + if (this.isTodoTool(tool)) return 'todo'; + if (this.isTaskTool(tool)) return 'task'; + if (this.isGitTool(tool)) return 'git'; + if (this.isDeleteTool(tool)) return 'delete'; + if (this.isDiffTool(tool)) return 'diff'; + if (this.isPatchTool(tool)) return 'patch'; + if (this.isFileCreateTool(tool)) return 'create'; + if (this.isFileMutationTool(tool)) return 'mutate'; + if (this.isFileReadTool(tool)) return this.isDirectoryListTool(tool) ? 'folder' : 'document'; + if (this.isSearchTool(tool)) return 'search'; + if (this.isWebTool(tool)) return 'web'; + if (this.isCommandTool(tool)) return 'command'; + return 'tool'; + } + private isQuestionLikeTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return this.isQuestionTool(tool) || normalized === 'askuserquestion' || normalized === 'ask_user_question'; @@ -1280,50 +998,6 @@ export struct ToolStatusList { } } - private toolInputTextForTool(tool: ConversationUiToolStatus): string { - const toolId = tool.id || ''; - if (this.toolInputEditToolId === toolId) { - return this.toolInputEditText; - } - return this.defaultToolInputText(tool); - } - - private toolInputErrorForTool(toolId: string): string { - return this.toolInputEditToolId === toolId ? this.toolInputEditError : ''; - } - - private approveToolWithInput(tool: ConversationUiToolStatus): void { - const toolId = tool.id || ''; - if (toolId.length === 0) { - return; - } - if (!this.hasEditableToolInput(tool)) { - this.onApproveTool(toolId); - return; - } - - const rawInput = this.toolInputTextForTool(tool).trim(); - if (rawInput.length === 0) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - - try { - const parsed = JSON.parse(rawInput) as Object; - if (parsed === null || Array.isArray(parsed)) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - this.toolInputEditError = ''; - this.onApproveTool(toolId, parsed); - } catch (_err) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonInvalid'); - } - } - private isRunningTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'running' || status === 'active') && (tool.id || '').length > 0; @@ -1387,16 +1061,6 @@ export struct ToolStatusList { return ''; } - private answerTextForTool(toolId: string): string { - return this.questionAnswerToolId === toolId ? this.questionAnswerText : ''; - } - - private canSubmitQuestion(toolId: string): boolean { - return toolId.length > 0 && - this.questionAnswerToolId === toolId && - this.questionAnswerText.trim().length > 0; - } - private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); if (tool.id && tool.id.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets new file mode 100644 index 0000000000..8db93d9fac --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -0,0 +1,319 @@ +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; +import { AppShellState } from '../state/AppShellState'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { FilePreviewSurface } from './FilePreviewSurface'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { FLOATING_PANEL_BG, LINE, PAGE_BG } from './Theme'; + +const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; + +@ComponentV2 +export struct WideConversationHost { + @Param route: AppRoute = AppRoute.ChatHome; + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param filePreviewLayout: FilePreviewLayout = new FilePreviewLayout(FilePreviewPlacement.Hidden); + @Param wideMasterPaneWidth: number = 0; + @Param wideMasterDetailGap: number = 0; + @Param wideDetailContentOffset: number = 0; + @Param wideDetailContentWidth: number = 0; + @Param wideCollapsedDetailContentOffset: number = 0; + @Param wideCollapsedDetailContentWidth: number = 0; + @Param wideMasterPaneCollapsed: boolean = false; + @Param wideMasterPaneMotionActive: boolean = false; + @Event onCollapseMasterPane: () => void = () => {}; + @Event onRestoreMasterPane: () => void = () => {}; + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { + this.GeneralChatContent(); + } else if (this.showsRemoteConversation() && + this.filePreviewLayout.placement === FilePreviewPlacement.WideFocusSplit) { + this.RemotePreviewFocusContent(); + } else if (this.showsRemoteConversation()) { + this.RemoteChatContent(); + } else if (this.route === AppRoute.RemoteHome) { + this.RemoteHomeContent(); + } else { + this.RemoteCreateContent(); + } + } + + @Builder + private GeneralChatContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.General, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteHomeContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + Column() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Placeholder, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + onRestoreSidebar: this.onRestoreMasterPane + }) + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteCreateContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private MasterPane(source: ConversationSource, showSelectedSession: boolean) { + Column() { + Column() { + AppSidebar({ + sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: source === ConversationSource.Remote ? '' : + this.generalPageState.conversation.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + showViewSettingsButton: source === ConversationSource.Remote, + showCustomContent: source === ConversationSource.Remote, + conversationSource: source, + contentSlot: () => { + this.RemoteMasterContent(showSelectedSession) + }, + onClose: this.actions.onSidebar.close, + onNewChat: source === ConversationSource.Remote ? + this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: this.onCollapseMasterPane, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (source === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: source === ConversationSource.Remote ? + this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + .width('100%').height('100%').backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18).clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(WideLayoutGeometry.masterPaneWidth(this.filePreviewLayout, this.wideMasterPaneWidth)) + .height('100%').padding({ left: 10, right: 6, top: 10, bottom: 10 }).backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : TransitionEffect.opacity(1)) + } + + @Builder + private RemoteMasterContent(showSelectedSession: boolean) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession, + compact: false + }) + } + + @Builder + private RemoteChatContent() { + if (this.filePreviewLayout.placement === FilePreviewPlacement.WideTriplePane) { + Row() { + this.MasterPane(ConversationSource.Remote, true) + this.PaneGap(this.filePreviewLayout.masterConversationGap) + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } else { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, true) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RemotePreviewFocusContent() { + Row() { + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private FilePreviewPane(paneWidth: number) { + Column() { + FilePreviewSurface({ + state: this.filePreviewState, + remoteAvailable: RemoteUiState.canUseRemote(this.remotePageState.connectionState), + downloadPath: this.remotePageState.downloadingFilePath, + downloadedPath: this.remotePageState.downloadedFilePath, + downloadStatus: this.remotePageState.fileDownloadStatus, + onClose: this.actions.onFilePreview.close, + onRefresh: this.actions.onFilePreview.refresh, + onDownload: this.actions.onFilePreview.download, + onOpenLink: this.actions.onFilePreview.openLink + }) + } + .width(paneWidth).height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private ConversationDetail(showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurface(showBackButton) + } + .width(paneWidth).height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } else { + Stack({ alignContent: Alignment.TopStart }) { + Row() { + if (this.currentDetailOffset() > 0) Blank().width(this.currentDetailOffset()) + Row() { + Column() { this.RouteSurface(showBackButton) } + .width('100%').height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } + .width(this.currentDetailWidth() > 0 ? this.currentDetailWidth() : '100%') + .height('100%').justifyContent(FlexAlign.Center) + if (this.currentDetailOffset() > 0) Blank().layoutWeight(1) + } + .width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(PAGE_BG) + + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) + .position({ x: this.currentDetailOffset() + 12, y: 12 }).zIndex(2) + .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RouteSurface(showBackButton: boolean) { + ConversationRouteSurface({ + route: this.route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: false, + showBackButton, + useWidePresentation: true, + contentHorizontalOffset: this.collapsedDetailVisualBias(), + onRestoreSidebar: this.onRestoreMasterPane + }) + } + + @Builder + private MasterDetailGap() { + if (this.wideMasterDetailGap > 0) { + Row() {}.width(this.wideMasterDetailGap).height('100%').backgroundColor(LINE) + } + } + + @Builder + private PaneGap(width: number) { + if (width > 0) { + Row() {}.width(width).height('100%').backgroundColor(LINE) + } + } + + private showsRemoteConversation(): boolean { + return this.route === AppRoute.RemoteChat; + } + + private currentDetailOffset(): number { + return WideLayoutGeometry.detailOffset( + this.wideMasterPaneCollapsed, + this.wideDetailContentOffset, + this.wideCollapsedDetailContentOffset + ); + } + + private currentDetailWidth(): number { + return WideLayoutGeometry.detailWidth( + this.wideMasterPaneCollapsed, + this.wideDetailContentWidth, + this.wideCollapsedDetailContentWidth + ); + } + + private collapsedDetailVisualBias(): number { + return WideLayoutGeometry.collapsedVisualBias( + this.wideMasterPaneCollapsed, + this.wideCollapsedDetailContentOffset, + this.wideCollapsedDetailContentWidth, + WIDE_DETAIL_CONTENT_MAX_WIDTH, + 72 + ); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets new file mode 100644 index 0000000000..11f1f41e09 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -0,0 +1,368 @@ +import { RemoteI18n } from '../../../i18n/RemoteI18n'; +import { RemoteSession } from '../../../model/RemoteModels'; +import { RemotePageState } from '../../state/RemotePageState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../../actions/AppRootPresentationActions'; +import { ConversationViewSettings } from '../ConversationViewSettings'; +import { GeneralChatHeader } from '../GeneralChatHeader'; +import { RemoteSessionList } from '../RemoteSessionList'; +import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; +import { SidebarToggleButton } from '../SidebarToggleButton'; +import { SessionActionPresentation } from '../SessionActionSurface'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from '../Theme'; + +export enum RemoteSurfaceMode { + Master = 'master', + CompactHome = 'compact_home', + Placeholder = 'placeholder', + Settings = 'settings' +} + +/** Shared presentation state for compact and wide Remote surfaces. */ +@ObservedV2 +export class RemoteSurfaceState { + @Trace sortMode: string = 'project'; + @Trace workspaceFilter: string = ''; + @Trace agentFilter: string = ''; + @Trace statusFilter: string = ''; + @Trace showWorkspaceMetadata: boolean = false; + @Trace showUpdatedMetadata: boolean = false; + @Trace showStatusMetadata: boolean = false; + + setSortMode(value: string): void { this.sortMode = value; } + setWorkspaceFilter(value: string): void { this.workspaceFilter = value; } + setAgentFilter(value: string): void { this.agentFilter = value; } + setStatusFilter(value: string): void { this.statusFilter = value; } + setWorkspaceMetadata(value: boolean): void { this.showWorkspaceMetadata = value; } + setUpdatedMetadata(value: boolean): void { this.showUpdatedMetadata = value; } + setStatusMetadata(value: boolean): void { this.showStatusMetadata = value; } +} + +@ComponentV2 +export struct RemoteSurfaceHost { + @Param mode: RemoteSurfaceMode = RemoteSurfaceMode.Master; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param presentationState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSelectedSession: boolean = false; + @Param compact: boolean = false; + @Param wideMasterPaneCollapsed: boolean = false; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onCloseSettings: () => void = () => {}; + + build() { + if (this.mode === RemoteSurfaceMode.Master) { + this.MasterContent(); + } else if (this.mode === RemoteSurfaceMode.CompactHome) { + this.CompactHomeContent(); + } else if (this.mode === RemoteSurfaceMode.Placeholder) { + this.FlowPlaceholder(); + } else { + this.SettingsContent(); + } + } + + @Builder + private MasterContent() { + Column() { + this.StatusRow() + if (this.isInitialLoading()) { + RemoteSessionLoadingView() + } else if (this.canShowSessionList()) { + RemoteSessionList({ + sessions: this.remotePageState.visibleSessions(), + query: this.remotePageState.sessionQuery, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + actionPresentation: SessionActionPresentation.Popover, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + hasMoreSessions: this.remotePageState.hasMoreSessions, + isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? + this.remotePageState.pendingSessionId : + (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), + onCreate: () => this.createSession('code'), + onCreateAssistantSession: () => this.createAssistantSession(), + onCreateInWorkspace: (path: string, agentType: string) => this.createSessionInWorkspace(path, agentType), + onSelectWorkspace: (path: string) => this.actions.onRemoteHome.selectWorkspace(path), + onOpenSession: (session: RemoteSession) => this.openSession(session), + onDeleteSession: (session: RemoteSession) => this.actions.onRemoteHome.deleteSession(session), + onLoadMore: () => this.actions.onRemoteHome.loadMore() + }) + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) + } + + @Builder + private StatusRow() { + Row({ space: 6 }) { + this.StatusIndicator() + Text(this.statusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .margin({ top: 16, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private StatusIndicator() { + if (this.isInitialLoading()) { + LoadingProgress().width(14).height(14).color(MUTED) + } else { + Stack() { + Text('') + } + .width(7) + .height(7) + .backgroundColor(this.statusColor()) + .borderRadius(4) + } + } + + @Builder + private DisconnectedState() { + Column({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')).fontSize(42).fontColor([INK]) + } + .width(74) + .height(74) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + Text(RemoteI18n.t('remote.connectTitle')) + .fontSize(18).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(RemoteI18n.t('remote.connectText')) + .fontSize(13).lineHeight(20).fontColor(MUTED).textAlign(TextAlign.Center) + Text(RemoteI18n.t('connect.connect')) + .width(136).height(44).fontSize(15).fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION).textAlign(TextAlign.Center).borderRadius(22) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } + .layoutWeight(1) + .width('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .padding({ left: 20, right: 20, bottom: 48 }) + } + + @Builder + private CompactHomeContent() { + Column() { + GeneralChatHeader({ + title: RemoteI18n.t('remote.title'), + subtitle: this.compactHeaderContext(), + showSidebarButton: true, + onOpenSidebar: this.onOpenSidebar + }) + if (this.canShowSessionList()) { + this.CompactEmptyState() + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private CompactEmptyState() { + Column({ space: 10 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.compactTitle()) + .fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(this.compactText()) + .fontSize(14).lineHeight(21).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + Text(RemoteI18n.t('remote.startSession')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.createAssistant()) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) + } + + @Builder + private FlowPlaceholder() { + Column() { + Row({ space: 8 }) { + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 48, onToggle: this.onRestoreSidebar }) + } else { + Blank().width(48).height(48) + } + Column({ space: 4 }) { + Text(RemoteI18n.t('remote.chats')).fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.desktopName()).fontSize(13).fontColor(MUTED).maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) + } + .width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + + Column({ space: 8 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.placeholderTitle()).fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.statusText()).fontSize(14).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 48 }) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private SettingsContent() { + ConversationViewSettings({ + sessions: this.remotePageState.visibleSessions(), + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + onSortModeChange: (value: string) => this.presentationState.setSortMode(value), + onWorkspaceFilterChange: (value: string) => this.presentationState.setWorkspaceFilter(value), + onAgentFilterChange: (value: string) => this.presentationState.setAgentFilter(value), + onStatusFilterChange: (value: string) => this.presentationState.setStatusFilter(value), + onWorkspaceMetadataChange: (value: boolean) => this.presentationState.setWorkspaceMetadata(value), + onUpdatedMetadataChange: (value: boolean) => this.presentationState.setUpdatedMetadata(value), + onStatusMetadataChange: (value: boolean) => this.presentationState.setStatusMetadata(value), + onClose: this.onCloseSettings + }) + } + + private openSession(session: RemoteSession): void { + if (this.compact) { + this.actions.onSidebar.openSession(session); + } else { + this.actions.onRemoteHome.openSessionInPlace(session); + } + } + + private createSession(agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.create(agentType); + } else { + this.actions.onRemoteHome.createInPlace(agentType); + } + } + + private createSessionInWorkspace(path: string, agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createInWorkspace(path, agentType); + } else { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + } + } + + private createAssistantSession(): void { + if (this.compact) { + this.actions.onSidebar.close(); + } + this.actions.onRemoteHome.createAssistant(); + } + + private canShowSessionList(): boolean { + return this.remotePageState.connectionState === 'connected' || + this.remotePageState.visibleSessions().length > 0 || + this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; + } + + private isInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isConnecting(); + } + + private isConnecting(): boolean { + return this.remotePageState.connectionState === 'parsing' || + this.remotePageState.connectionState === 'pairing' || + this.remotePageState.connectionState === 'reconnecting'; + } + + private statusText(): string { + if (this.remotePageState.conversation.statusText.length > 0) { + return this.remotePageState.conversation.statusText; + } + return this.desktopName(); + } + + private statusColor(): ResourceColor { + if (this.remotePageState.connectionState === 'connected') return GREEN; + if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { + return RED; + } + return MUTED; + } + + /** + * Compact Remote Home names the bound desktop under the title, the same + * context the conversation header carries. Stays empty while disconnected so + * the connect state does not advertise a stale desktop. + */ + private compactHeaderContext(): string { + return this.canShowSessionList() ? this.remotePageState.desktopName : ''; + } + + private desktopName(): string { + return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : + RemoteI18n.t('remote.settings.noDesktop'); + } + + private compactTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); + } + + private compactText(): string { + if (this.isInitialLoading()) return this.statusText(); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); + } + + private placeholderTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets new file mode 100644 index 0000000000..4b3de88883 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets @@ -0,0 +1,35 @@ +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; + +/** Pure geometry helpers shared by wide conversation presentation paths. */ +export class WideLayoutGeometry { + static masterPaneWidth(layout: FilePreviewLayout, fallback: number): number { + return layout.placement === FilePreviewPlacement.WideTriplePane ? layout.masterPaneWidth : fallback; + } + + static detailOffset(collapsed: boolean, expandedOffset: number, collapsedOffset: number): number { + return collapsed ? collapsedOffset : expandedOffset; + } + + static detailWidth(collapsed: boolean, expandedWidth: number, collapsedWidth: number): number { + return collapsed ? collapsedWidth : expandedWidth; + } + + static collapsedVisualBias( + collapsed: boolean, + collapsedOffset: number, + collapsedWidth: number, + maxContentWidth: number, + maximumBias: number + ): number { + if (!collapsed || collapsedOffset > 0) { + return 0; + } + const availableMargin = (collapsedWidth - maxContentWidth) / 2; + return Math.min(maximumBias, Math.max(0, availableMargin)); + } + + static areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets index 417e97869e..58ca1bf03e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets @@ -1,8 +1,8 @@ -import { SelectedImageAttachment } from '../model/RemoteModels'; -import { AppRoute, AppRouteContract } from '../pages/navigation/AppRouteContract'; -import { GeneralChatPageState } from '../pages/state/GeneralChatPageState'; -import { RemotePageState } from '../pages/state/RemotePageState'; -import { VoiceInputRouteSnapshot } from './VoiceInputLifecycleController'; +import { SelectedImageAttachment } from '../../model/RemoteModels'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppRoute, AppRouteContract } from './AppRouteContract'; /** Keeps route-dependent composer state mapping out of the root component. */ export class AppRootRouteState { @@ -11,16 +11,19 @@ export class AppRootRouteState { } static chatInput(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): string { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.chatInput : remote.chatInput; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.chatInput : remote.conversation.chatInput; } static selectedImages(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): SelectedImageAttachment[] { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.selectedImages : remote.selectedImages; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.selectedImages : remote.conversation.selectedImages; } static voiceListening(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): boolean { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.isVoiceListening : remote.isVoiceListening; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.isVoiceListening : remote.conversation.isVoiceListening; } static setChatInput(route: AppRoute, value: string, general: GeneralChatPageState, remote: RemotePageState): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 0ca41eeb88..32a712b546 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -85,6 +85,10 @@ export class AppRouteContract { return new ChatRouteParam(sessionId); } + static remoteSessionDestination(sessionId: string): AppNavigationPathSpec { + return new AppNavigationPathSpec(AppRoute.RemoteChat, sessionId); + } + static pathSpec(currentRoute: AppRoute, route: AppRoute, sessionId: string = ''): AppNavigationPathSpec | undefined { if (currentRoute === route) { return undefined; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets new file mode 100644 index 0000000000..7d34cede75 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -0,0 +1,390 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { + AppRootRuntimeComposition, + ConnectionState +} from './AppRootRuntimeComposition'; + +export class AppRootRuntime extends AppRootRuntimeComposition { + constructor(host: AppRootHostPort) { + super(host); + } + + async aboutToAppear(): Promise { + this.syncRemotePageSummary(); + await this.generalChatBootstrapController.restore(this.host.context()); + await this.settingsController.initializeCloudAccount(this.host.context()); + await this.settingsController.refreshModelCatalog(); + await this.restoreIdentity(); + } + + onPageShow(): void { + RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.resume(); + } + + onPageHide(): void { + RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + } + + aboutToDisappear(): void { + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + this.generalChatConversationViewModel.stop(true, 'failed'); + this.generalChatDraftLifecycleController.cancel(); + this.remoteFileDownloadController.cancel(); + this.filePreviewController.close(); + this.voiceInputLifecycleController.cancel(`${this.appShellViewModel.currentRoute()}`, () => { + this.conversationController.clearAllVoiceListening(); + }); + } + + isRemoteConversationContext(sessionId: string): boolean { + if (sessionId.length === 0 || this.remotePageState.activeSession.sessionId !== sessionId) { + return false; + } + return this.appShellViewModel.isRoute(AppRoute.RemoteChat) || this.appShellViewModel.isRoute(AppRoute.RemoteHome); + } + + handleNavigationBack(route: AppRoute): boolean { + if (this.filePreviewState.visible) { + this.filePreviewController.close(); + return true; + } + const action = this.appShellViewModel.backAction(route); + if (action === AppNavigationBackAction.CloseSidebar) { + this.closeAppSidebar(); + return true; + } + if (action === AppNavigationBackAction.CloseActiveChat) { + this.exitActiveChat(); + return true; + } + if (action === AppNavigationBackAction.PopRemoteHome) { + this.appShellViewModel.popRoute(AppRoute.ChatHome); + return true; + } + return false; + } + + handleRootBack(): boolean { + if (!this.filePreviewState.visible) { + return false; + } + this.filePreviewController.close(); + return true; + } + + + async restoreIdentity(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + return; + } + await this.remoteConnectionController.restore(this.host.context()); + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + await this.remoteConnectionController.connect(autoReconnect, accountPassword); + await this.settingsController.persistDelegatedAccountSession(); + } + + async reconnect(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + await this.settingsController.restoreCloudTarget( + this.remotePageState.controlTargetDeviceId, + this.remotePageState.controlTargetDeviceName + ); + return; + } + await this.remoteConnectionController.reconnect(); + } + + async disconnect(clearPairing: boolean): Promise { + this.filePreviewController.invalidate(); + await this.remoteConnectionController.disconnect(clearPairing); + } + + syncRemotePageSummary(): void { + if (this.remotePageState.statusText.length === 0) { + this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); + } + if (this.remotePageState.workspaceName.length === 0) { + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + } + } + + failRemoteConnection(err: Object): void { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.remotePageState.setConnectionState(ConnectionState.Failed); + this.remoteActivityViewModel.stopHeartbeat(); + } + + async selectWorkspace(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectWorkspace(path); + } + + async selectAssistant(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectAssistant(path); + } + + openAppSidebar(): void { + this.host.animate(230, () => { + this.appShellState.setSidebarVisible(true); + }); + } + + closeAppSidebar(): void { + this.host.animate(210, () => { + this.appShellState.setSidebarVisible(false); + }); + } + + enterCodeEntry(): void { + if (this.settingsController.hasCloudAccountSession() && this.remotePageState.accountUserId.trim().length > 0) { + this.appShellState.setConnectSheetVisible(true); + return; + } + if (RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState))) { + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellState.setConnectSheetVisible(true); + } + + async switchWideConversationSource(source: ConversationSource): Promise { + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = this.remotePageState.isConversationDismissed ? '' : + (this.remotePageState.activeSession.sessionId || ''); + const target = AppRouteContract.routeForConversationSource( + source, + RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeRemoteSessionId + ); + this.appShellViewModel.replaceRouteWithoutAnimation( + target.name, + target.hasSessionParam() ? target.routeParam().sessionId : '' + ); + if (target.name === AppRoute.RemoteChat) { + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + } + + /** + * Compact counterpart of switchWideConversationSource. Switching source is a + * change of context, not a command to start something: it resumes the session + * the user was last in, and otherwise rests on the Remote landing surface + * rather than opening the create composer for them. + */ + async switchCompactConversationSource(source: ConversationSource): Promise { + this.closeAppSidebar(); + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)) && + !this.remotePageState.isConversationDismissed ? + (this.remotePageState.activeSession.sessionId || '') : ''; + if (activeRemoteSessionId.length === 0) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + + enterCompactLayout(): void { + const sessionId = this.remotePageState.activeSession.sessionId || ''; + if (this.appShellViewModel.isRoute(AppRoute.RemoteHome) && + !this.remotePageState.isConversationDismissed && sessionId.length > 0) { + this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); + } + } + + /** + * Exit control for an open conversation. Leaving a remote conversation on a + * compact layout lands on Remote Home with no visible session list, so reveal + * the drawer that owns navigation there. Compact chats have no back button of + * their own, so this runs for the system back gesture. + */ + exitActiveChat(): void { + const revealSidebar = !this.appShellState.wideLayout && + this.appShellViewModel.isRoute(AppRoute.RemoteChat); + this.conversationController.closeActiveChat(); + if (revealSidebar) { + this.openAppSidebar(); + } + } + + openRemoteControlSettings(): void { + setTimeout(() => { + this.appShellState.openSettings('remote'); + }, 180); + } + + openAddConnectionFromSettings(): void { + this.appShellState.setSettingsVisible(false); + setTimeout(() => { + this.appShellState.setConnectSheetVisible(true); + }, 220); + } + + applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { + this.remoteWorkspaceSessions = all; + const current = this.remotePageState.sessions; + const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.remotePageState.workspacePath); + this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { + const merged = primary.slice(); + extras.forEach((item: RemoteSession) => { + if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { + merged.push(item); + } + }); + return merged; + } + + async toggleVoiceInput(): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.toggle( + this.host.context(), this.conversationController.voiceInputSnapshot(route) + ); + } + + async stopVoiceInput(showStatus: boolean): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.stop( + this.conversationController.voiceInputSnapshot(route), showStatus + ); + } + + showVoiceInputError(message: string): void { + const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); + this.conversationController.setVisibleStatusText(text); + this.host.showToast(text, 2600); + } + + async pickImages(): Promise { + if (this.conversationController.visibleBusy()) { + return; + } + const route = this.appShellViewModel.currentRoute(); + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + try { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.pickImage')); + const picked = await this.imagePickerService.pickImages( + 3, + this.conversationController.visibleSelectedImages().length + ); + if (picked.length === 0) { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); + return; + } + this.conversationController.addSelectedImages(route, picked); + this.conversationController.setVisibleStatusText(RemoteI18n.f( + 'status.imagesSelected', + `${this.conversationController.visibleSelectedImages().length}` + )); + } catch (err) { + this.conversationController.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + currentActiveTurnId(): string { + if (!this.appShellViewModel.isGeneralChatVisible()) { + return this.conversationController.remoteActiveTurnId(); + } + const activeTurnMessage = this.generalChatPageState.activeTurnMessage; + if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { + return activeTurnMessage.turnId; + } + const activePrefix = 'active-'; + if (activeTurnMessage.id.indexOf(activePrefix) === 0) { + return activeTurnMessage.id.slice(activePrefix.length); + } + return ''; + } + + hasRemoteBindingForResume(): boolean { + if (this.remotePageState.controlTargetType === 'account_device') { + return this.remotePageState.accountUserId.trim().length > 0 && + this.remotePageState.controlTargetDeviceId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Parsing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Pairing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + + async reconnectActiveRemote(): Promise { + if (this.remotePageState.controlTargetType !== 'account_device') { + await this.connect(true); + return; + } + const targetId = this.remotePageState.controlTargetDeviceId; + const device = (await this.settingsController.listCloudAccountDevices()) + .find((item: CloudAccountDevice): boolean => item.deviceId === targetId); + if (!device) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + await this.settingsController.selectCloudAccountDevice(device); + } + + hasRemoteBindingForCodeHome(): boolean { + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Pairing || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Parsing); + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets new file mode 100644 index 0000000000..c2d87b233f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -0,0 +1,803 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemotePermissionMode, + RemoteQuestionAnswerPayload, + RemoteSession, + SelectedImageAttachment, + SessionSummary, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; +import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { SettingsController } from '../viewmodel/SettingsController'; +import { ConversationController } from '../viewmodel/ConversationController'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; +import { VoiceInputService } from '../../services/VoiceInputService'; +import { ConversationIntent } from '../actions/ConversationIntent'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { AppShellViewModel } from '../viewmodel/AppShellViewModel'; +import { RemoteActivityViewModel } from '../viewmodel/RemoteActivityViewModel'; +import { + RemoteConnectionController +} from '../viewmodel/RemoteConnectionController'; +import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispatcher'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'; +import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel'; +import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel'; +import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; + +export enum ConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; +const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; + +export abstract class AppRootRuntimeComposition { + readonly host: AppRootHostPort; + + constructor(host: AppRootHostPort) { + this.host = host; + } + + abstract applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void; + abstract closeAppSidebar(): void; + abstract connect(autoReconnect?: boolean, accountPassword?: string): Promise; + abstract currentActiveTurnId(): string; + abstract disconnect(clearPairing: boolean): Promise; + abstract enterCodeEntry(): void; + abstract enterCompactLayout(): void; + abstract exitActiveChat(): void; + abstract failRemoteConnection(err: Object): void; + abstract handleNavigationBack(route: AppRoute): boolean; + abstract hasRemoteBindingForResume(): boolean; + abstract isRemoteConversationContext(sessionId: string): boolean; + abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; + abstract openAddConnectionFromSettings(): void; + abstract openAppSidebar(): void; + abstract openRemoteControlSettings(): void; + abstract pickImages(): Promise; + abstract reconnect(): Promise; + abstract reconnectActiveRemote(): Promise; + abstract selectAssistant(path: string): Promise; + abstract selectWorkspace(path: string): Promise; + abstract showVoiceInputError(message: string): void; + abstract stopVoiceInput(showStatus: boolean): Promise; + abstract switchCompactConversationSource(source: ConversationSource): Promise; + abstract switchWideConversationSource(source: ConversationSource): Promise; + abstract toggleVoiceInput(): Promise; + + readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly workspaceRepository: RemoteWorkspaceRepository = + new RemoteWorkspaceRepository(this.sessionManager); + readonly workspaceCoordinator: RemoteWorkspaceCoordinator = + new RemoteWorkspaceCoordinator(this.workspaceRepository); + readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly filePreviewState: FilePreviewState = new FilePreviewState(); + readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); + readonly clipboardService: ClipboardService = new ClipboardService(); + readonly qrScanService: QrScanService = new QrScanService(); + readonly imagePickerService: ImagePickerService = new ImagePickerService(); + readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); + readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = + new RemoteConnectionCoordinator( + this.sessionManager, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionGate + ); + readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); + readonly remotePageState: RemotePageState = new RemotePageState(); + readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); + readonly generalChatController: GeneralChatController = + GeneralChatController.createDefault(this.generalChatConfigStore); + readonly generalChatDraftController: GeneralChatDraftController = + new GeneralChatDraftController( + this.generalChatController, + GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, + (err: Error) => { + RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); + } + ); + readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = + new GeneralChatDraftLifecycleController( + this.generalChatDraftController, + GENERAL_CHAT_HOME_DRAFT_ID, + (): string => this.conversationController.visibleGeneralChatDraftId() + ); + readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + readonly generalChatCommandController: GeneralChatCommandController = + new GeneralChatCommandController( + this.generalChatController, + { + onSessions: (sessions: RemoteSession[]) => { + this.generalChatPageState.setSessions(sessions); + }, + onSessionPrepared: (sessionId: string) => { + this.conversationController.resetGeneralTimeline(sessionId); + this.remoteModelController.clearCatalog(); + }, + onActiveSession: (session: SessionSummary) => { + this.generalChatPageState.setActiveSession(session); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.conversationController.syncGeneralTimeline(); + }, + onClearComposer: () => { + this.generalChatPageState.clearComposer(); + }, + onChatInput: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + }, + onBusy: (isBusy: boolean) => { + this.generalChatPageState.setBusy(isBusy); + }, + onToast: (statusText: string) => { + this.conversationController.showHomeToast(statusText); + } + } + ); + readonly generalChatBootstrapController: GeneralChatBootstrapController = + new GeneralChatBootstrapController( + this.generalChatConfigStore, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + this.settingsController.apply(snapshot); + }, + onHomeDraftRestored: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + } + } + ); + readonly voiceInputService: VoiceInputService = new VoiceInputService(); + readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = + new RemoteActivityLifecycleController(() => { + this.remoteActivityViewModel.checkConnectionHealth(); + }); + readonly remoteActivityViewModel: RemoteActivityViewModel = + new RemoteActivityViewModel( + this.remoteActivityLifecycleController, + this.remoteConnectionCoordinator, + this.remoteResumeGate, + { + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + hasRemoteBinding: (): boolean => this.hasRemoteBindingForResume(), + isRemoteChat: (): boolean => this.appShellViewModel.isRoute(AppRoute.RemoteChat), + activeSession: (): SessionSummary => this.remotePageState.activeSession, + onConnectionState: (state: string): void => this.remotePageState.setConnectionState(state as ConnectionState), + onStatus: (status: string): void => this.remotePageState.setStatusText(status), + onConnectionError: async (err: Object): Promise => this.settingsController.handleRemoteConnectionError(err), + onStopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onPoll: async (): Promise => { + await this.remoteChatPollingLifecycleController.pollNow(); + }, + onReconnect: async (): Promise => { + await this.reconnectActiveRemote(); + }, + onRestoreSession: async (session: SessionSummary): Promise => { + this.conversationController.applyRemoteActiveSession(session); + await this.conversationController.loadRemoteMessages(); + } + } + ); + readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = + new GeneralChatStreamLifecycleController(); + readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = + new RemoteWorkspaceViewModel( + this.remotePageState, + this.workspaceCoordinator, + { + isRemoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (isBusy: boolean): void => { + this.remotePageState.setBusy(isBusy); + }, + onStatus: (statusText: string): void => { + this.remotePageState.setStatusText(statusText); + }, + onWorkspaceSelected: (workspace: WorkspaceInfo): void => { + this.remoteConnectionController.applyWorkspace(workspace); + this.remoteSessionController.clearSessions(); + }, + onSessionsDiscovered: (sessions: RemoteSession[]): void => { + this.applyDiscoveredWorkspaceSessions(sessions); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionViewModel.refreshSessions(); + }, + onConnectionFailure: (error: Object): void => { + this.failRemoteConnection(error); + } + } + ); + remoteWorkspaceSessions: RemoteSession[] = []; + readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); + readonly appShellState: AppShellState = this.appShellViewModel.state; + readonly voiceInputLifecycleController: VoiceInputLifecycleController = + new VoiceInputLifecycleController( + this.voiceInputService, + { + currentInputText: (): string => this.conversationController.visibleChatInput(), + currentStatusText: (): string => this.conversationController.visibleStatusText(), + onInputText: (routeId: string, text: string) => { + this.conversationController.setChatInput(routeId as AppRoute, text); + }, + onListening: (routeId: string, isListening: boolean) => { + this.conversationController.setVoiceListening(routeId as AppRoute, isListening); + }, + onStatusText: (statusText: string) => { + this.conversationController.setVisibleStatusText(statusText); + }, + onError: (message: string) => { + this.showVoiceInputError(message); + } + } + ); + readonly remoteSessionController: RemoteSessionController = + new RemoteSessionController( + this.sessionManager, + 8, + { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { + return item.workspacePath !== this.remotePageState.workspacePath; + }); + this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onLoading: (isLoading: boolean) => { + this.remotePageState.setLoading(isLoading); + }, + onSessionError: (errorText: string) => { + this.remotePageState.setError(errorText); + }, + onReconnecting: () => { + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + }, + onConnected: () => { + this.remotePageState.setConnectionState(ConnectionState.Connected); + }, + onConnectionFailed: (err: Object) => { + this.failRemoteConnection(err); + }, + onStartHeartbeat: () => { + this.remoteActivityViewModel.startHeartbeat(); + } + } + ); + readonly remoteChatCommandController: RemoteChatCommandController = + new RemoteChatCommandController( + this.sessionManager, + { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.remotePageState.setHasMoreMessages(hasMoreMessages); + this.conversationController.syncRemoteTimeline(); + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + this.conversationController.updateKnownMessageCount(pollVersion, knownMessageCount); + }, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + if (turnId.length > 0) { + this.chatTimelineStore.setLocalActiveTurn(turnId); + this.conversationController.syncRemoteTimeline(); + } else if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + this.conversationController.syncRemoteTimeline(); + } + this.remoteChatPollingLifecycleController.nudge(); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + this.remotePageState.setChatInput(rawText); + this.remotePageState.setSelectedImages(images); + this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); + if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + } + this.conversationController.syncRemoteTimeline(); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + this.remoteSessionController.updateSessionTitle(sessionId, title); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onPollRequested: () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + } + ); + readonly remoteFileDownloadController: RemoteFileDownloadController = + new RemoteFileDownloadController( + this.sessionManager, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); + }, + () => { + this.remotePageState.clearDownloadingFilePath(); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly filePreviewController: FilePreviewController = + new FilePreviewController( + this.sessionManager, + this.filePreviewState, + { + remoteAvailable: (): boolean => RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeSession: (): SessionSummary => this.remotePageState.conversation.activeSession, + workspacePath: (): string => this.remotePageState.workspacePath, + openExternalLink: async (reference: string): Promise => + this.host.openExternalLink ? await this.host.openExternalLink(reference) : false, + onGeneralStatus: (statusText: string): void => this.generalChatPageState.setStatus(statusText), + onRemoteStatus: (statusText: string): void => this.remotePageState.setStatusText(statusText) + } + ); + readonly remoteToolActionController: RemoteToolActionController = + new RemoteToolActionController( + this.sessionManager, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + ); + readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = + new RemoteChatPollingLifecycleController( + this.sessionManager, + { + canPoll: (sessionId: string) => { + return this.remotePageState.activeSession.sessionId === sessionId && + this.isRemoteConversationContext(sessionId) && + this.remoteConnectionController.ensureAvailable(); + }, + onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { + this.conversationController.applyRemoteSnapshot(snapshot); + }, + onError: (error: Object) => { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error)); + } + } + ); + readonly remoteModelController: RemoteModelController = + new RemoteModelController( + this.sessionManager, + this.identityStore, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly remoteSessionViewModel: RemoteSessionViewModel = + new RemoteSessionViewModel( + this.remotePageState, + this.remoteSessionController, + this.remoteChatCommandController, + this.remoteModelController, + this.remoteFileDownloadController, + { + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (busy: boolean): void => this.remotePageState.setBusy(busy), + onRouteChat: (sessionId: string): void => this.conversationController.routeCreatedRemoteSession(sessionId), + onRouteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), + onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), + onLoadModelCatalog: async (sessionId: string): Promise => { + await this.conversationController.loadRemoteModelCatalog(sessionId); + }, + onLoadActiveMessages: async (): Promise => { + await this.conversationController.loadRemoteMessages(); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionController.refresh( + this.remotePageState.sessionQuery, + this.remotePageState.sessionFilter, + this.remoteConnectionController.ensureAvailable(), + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected + ); + }, + onSelectWorkspace: async (path: string): Promise => { + await this.selectWorkspace(path); + } + } + ); + readonly generalChatConversationViewModel: GeneralChatConversationViewModel = + new GeneralChatConversationViewModel( + this.generalChatPageState, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + this.generalChatStreamLifecycleController, + this.chatTimelineStore, + { + isVisible: (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && + this.appShellViewModel.isGeneralChatVisible(), + currentActiveTurnId: (): string => this.currentActiveTurnId(), + latestUserMessageText: (): string => this.conversationController.latestUserMessageText(), + syncTimeline: (): void => this.conversationController.syncGeneralTimeline(), + refreshSessions: (): void => this.generalChatCommandController.refreshSessions() + } + ); + readonly remoteConnectionController: RemoteConnectionController = + new RemoteConnectionController( + this.remotePageState, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionCoordinator, + this.remoteSessionController, + this.remoteModelController, + this.remoteFileDownloadController, + this.clipboardService, + this.qrScanService, + (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + (): void => this.conversationController.resetKnownRemoteState(), + (): void => this.remoteActivityViewModel.startHeartbeat(), + (): void => this.remoteActivityViewModel.stopHeartbeat(), + (): void => this.remoteChatPollingLifecycleController.stop(), + async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + }, + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), + (): void => this.appShellState.setConnectSheetVisible(false), + (): void => this.appShellState.setConnectSheetVisible(true) + ); + readonly settingsController: SettingsController = + new SettingsController( + this.generalChatConfigStore, + this.generalChatPageState, + { + probeConfiguration: async (apiUrl: string, apiKey: string, modelName: string): Promise => { + await ModelProviderGeneralChatAdapter.probeConfiguration(apiUrl, apiKey, modelName); + } + }, + { + client: new CloudAccountClient(), + sessionStore: new CloudAccountSessionStore(), + sessionManager: this.sessionManager, + remoteState: this.remotePageState, + hooks: { + deviceId: (): string => this.remoteConnectionController.getDeviceId(), + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + invalidatePreview: (): void => this.filePreviewController.invalidate(), + invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), + invalidateRemoteConnection: (): void => this.remoteConnectionCoordinator.invalidate(), + stopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + stopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + startHeartbeat: (): void => this.remoteActivityViewModel.startHeartbeat(), + resetTimeline: (): void => this.conversationController.resetRemoteTimeline(''), + resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), + closeSettings: (): void => this.appShellState.setSettingsVisible(false), + closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), + navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + loadRecentWorkspaces: async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + } + } + ); + readonly conversationController: ConversationController = + new ConversationController( + this.generalChatPageState, + this.remotePageState, + this.remoteCreateState, + { currentRoute: (): AppRoute => this.appShellViewModel.currentRoute() }, + { + timeline: this.chatTimelineStore, + chat: this.remoteChatCommandController, + polling: this.remoteChatPollingLifecycleController, + models: this.remoteModelController, + files: this.remoteFileDownloadController, + tools: this.remoteToolActionController, + connection: this.remoteConnectionController, + imagePicker: this.imagePickerService, + clipboard: this.clipboardService, + sessions: this.remoteSessionViewModel, + sessionManager: this.sessionManager, + workspace: this.workspaceCoordinator, + settings: this.settingsController, + appShell: this.appShellViewModel, + filePreview: this.filePreviewController, + generalCommands: this.generalChatCommandController, + generalConversation: this.generalChatConversationViewModel, + generalDrafts: this.generalChatDraftLifecycleController, + hooks: { + isConversationContext: (sessionId: string): boolean => this.isRemoteConversationContext(sessionId), + isFilePreviewVisible: (): boolean => this.filePreviewState.visible, + stopVoiceInput: async (): Promise => this.stopVoiceInput(false), + showToast: (message: string): boolean => this.host.showToast(message, 2600), + selectAssistantWorkspace: async (path: string): Promise => { + await this.selectAssistant(path); + } + } + } + ); + readonly conversationIntentDispatcher: ConversationIntentDispatcher = + new ConversationIntentDispatcher({ + openSidebar: (): void => this.openAppSidebar(), + back: (): void => this.exitActiveChat(), + newRemoteSession: (): void => { this.conversationController.createRemoteSession('code'); }, + newGeneralSession: (): void => this.conversationController.prepareNewGeneralChat(), + activeGeneralSession: (): RemoteSession => this.conversationController.activeGeneralChatAsRemoteSession(), + activeGeneralSessionId: (): string => this.generalChatPageState.activeSession.sessionId, + isGeneralBusy: (): boolean => this.generalChatPageState.isBusy, + isPinned: (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, + pin: async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { + await this.generalChatCommandController.pinSession(session, pinned, busy); + }, + archive: async (session: RemoteSession): Promise => { + await this.conversationController.archiveHomeSession(session, true); + }, + delete: async (session: RemoteSession): Promise => { + await this.conversationController.deleteHomeSession(session); + this.conversationController.prepareNewGeneralChat(); + }, + showToast: (text: string): void => this.conversationController.showHomeToast(text), + uploadedFileCount: (): number => this.conversationController.activeGeneralUploadedFileCount(), + stop: async (): Promise => { await this.conversationController.stopVisibleTask(); }, + loadOlder: async (): Promise => { await this.conversationController.loadOlderRemoteMessages(); }, + approve: async (id: string, input?: Object): Promise => { + await this.conversationController.approveRemoteTool(id, input); + }, + reject: async (id: string): Promise => { await this.conversationController.rejectRemoteTool(id); }, + cancel: async (id: string): Promise => { await this.conversationController.cancelRemoteTool(id); }, + answer: async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { + await this.conversationController.answerRemoteQuestion(id, answers); + }, + rename: async (title: string): Promise => { + await this.conversationController.renameVisibleSession(title); + }, + copy: async (text: string): Promise => { await this.conversationController.copyRemoteMessage(text); }, + retry: async (text: string): Promise => { await this.conversationController.retryVisibleMessage(text); }, + selectModel: async (id: string): Promise => { await this.conversationController.selectVisibleModel(id); }, + pickImages: async (): Promise => { await this.pickImages(); }, + removeImage: (id: string): void => this.conversationController.removeSelectedImage(this.appShellViewModel.currentRoute(), id), + openFilePreview: (route: AppRoute, request: FilePreviewRequest): void => + this.filePreviewController.open(route, request), + downloadFile: (path: string): void => this.conversationController.downloadVisibleFile(path), + send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + inputChanged: (route: AppRoute, value: string): void => + this.conversationController.onVisibleChatInputChange(route, value) + }); + readonly presentationActions: AppRootPresentationActions = { + onNavigationBack: (route: AppRoute): boolean => this.handleNavigationBack(route), + onConversationIntent: (route: AppRoute, intent: ConversationIntent): void => + this.conversationIntentDispatcher.dispatch(route, intent), + onCloseSidebar: (): void => this.closeAppSidebar(), + onWideConversationSource: (source: ConversationSource): void => { + this.switchWideConversationSource(source); + }, + onCompactConversationSource: (source: ConversationSource): void => { + this.switchCompactConversationSource(source); + }, + onCompactLayoutEntered: (): void => this.enterCompactLayout(), + onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), + onRemoteHome: { + openSidebar: (): void => this.openAppSidebar(), + connectWorkspace: (): void => this.enterCodeEntry(), + addConnection: (): void => this.appShellState.setConnectSheetVisible(true), + openSettings: (): void => this.openRemoteControlSettings(), + refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, + showAssistants: (): void => { this.remoteWorkspaceViewModel.toggleAssistants(); }, + selectWorkspace: (path: string): void => { this.selectWorkspace(path); }, + selectAssistant: (path: string): void => { this.selectAssistant(path); }, + cancelWorkspace: (): void => this.remotePageState.setWorkspacePickerVisible(false), + cancelAssistant: (): void => this.remotePageState.setAssistantPickerVisible(false), + queryChanged: (query: string): void => this.remotePageState.setQuery(query), + search: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + loadMore: (): void => { this.remoteSessionViewModel.loadMoreSessions(); }, + reconnect: (): void => { this.reconnect(); }, + disconnect: (): void => { this.disconnect(false); }, + clearPairing: (): void => { this.disconnect(true); }, + create: (agentType: string): void => { this.conversationController.createRemoteSession(agentType); }, + createInPlace: (agentType: string): void => { + this.conversationController.createRemoteSession(agentType, true); + }, + createAssistant: (): void => { this.conversationController.openRemoteCreateSession(); }, + createInWorkspace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType); + }, + createInWorkspaceInPlace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, true); + }, + openSession: (session: RemoteSession): void => this.conversationController.openHomeSession(session), + openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onRemoteCreate: { + back: (): void => this.conversationController.closeRemoteCreateSession(), + toggleDevices: (): void => { this.conversationController.toggleRemoteCreateDevices(); }, + toggleWorkspaces: (): void => { this.conversationController.toggleRemoteCreateWorkspaces(); }, + selectDevice: (device: CloudAccountDevice): void => { + this.conversationController.selectRemoteCreateDevice(device); + }, + selectWorkspace: (path: string): void => this.conversationController.selectRemoteCreateWorkspace(path), + draftChanged: (value: string): void => this.remoteCreateState.setDraft(value), + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + selectModel: (modelId: string): void => this.remoteCreateState.setSelectedModelId(modelId), + send: (): void => { this.conversationController.submitRemoteCreateSession(); } + }, + onSidebar: { + close: (): void => this.closeAppSidebar(), + newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, + enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, + settings: (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, + openAccount: (): void => { + this.closeAppSidebar(); + setTimeout(() => this.appShellState.openSettings('account'), 180); + }, + openSession: (session: RemoteSession): void => { + this.closeAppSidebar(); + this.conversationController.openHomeSession(session); + }, + archive: (session: RemoteSession, archived: boolean): void => { + this.conversationController.archiveHomeSession(session, archived); + }, + exportSession: (session: RemoteSession): void => { this.conversationController.exportHomeSession(session); }, + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onSettings: { + close: (): void => this.appShellState.leaveSettings(), + addConnection: (): void => this.openAddConnectionFromSettings(), + disconnect: (): void => { this.disconnect(false); }, + reconnect: (): void => { this.reconnect(); }, + openAccount: (): void => { this.appShellState.openSettings('account'); }, + cloudLogin: (relayUrl: string, username: string, password: string): Promise => + this.settingsController.loginCloudAccount(relayUrl, username, password), + cloudSync: (): Promise => this.settingsController.syncCloudAccount(), + cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), + setPermissionMode: (mode: RemotePermissionMode): Promise => + this.settingsController.setRemotePermissionMode(mode), + testGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.test(url, key, model, clear), + saveGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.save(url, key, model, clear) + }, + onConnect: { + back: (): void => this.appShellState.setConnectSheetVisible(false), + connect: (password?: string): void => { + // Keep connection progress on the same RemoteHome surface as the connected state. + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + this.connect(false, password || ''); + }, + clearPairing: (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, + urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); this.remoteConnectionController.projectRemoteUrl(url); }, + userChanged: (user: string): void => this.remotePageState.setUserId(user), + detected: (url: string): boolean => this.remoteConnectionController.handleDetectedUrl(url), + inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), + paste: (): void => { this.remoteConnectionController.paste(); }, + scan: (): void => { this.remoteConnectionController.scan(this.host.context()); }, + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + cloudSelectDevice: (device: CloudAccountDevice): Promise => + this.settingsController.selectCloudAccountDevice(device) + }, + onFilePreview: { + close: (): void => this.filePreviewController.close(), + refresh: (): void => this.filePreviewController.refresh(), + download: (path: string): void => this.conversationController.downloadVisibleFile(path), + openLink: (reference: string, label: string): void => this.filePreviewController.openLink(reference, label) + }, + generalStatus: (): string => this.conversationController.generalChatHomeStatusText() + }; + readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + + +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets deleted file mode 100644 index b4b50b623b..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ /dev/null @@ -1,2608 +0,0 @@ -import { - ChatMessage, - RecentWorkspaceEntry, - RemoteModelCatalog, - RemotePermissionMode, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteSession, - SelectedImageAttachment, - SessionSummary, - WorkspaceInfo -} from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ClipboardService } from '../../services/ClipboardService'; -import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; -import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ImagePickerService } from '../../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate, - GeneralChatConfigValidator, - GeneralChatModelSelectionPolicy -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; -import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../../services/general-chat/GeneralChatPort'; -import { MobileIdentityStore } from '../../services/MobileIdentityStore'; -import { CloudAccountClient, CloudAccountDevice, CloudAccountRequestError, CloudAccountSession } from '../../services/CloudAccountClient'; -import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; -import { Encoding } from '../../services/Encoding'; -import { AppRootRouteState } from '../../services/AppRootRouteState'; -import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; -import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; -import { RemoteFilePreviewController } from '../../services/RemoteFilePreviewController'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; -import { RemoteSessionController } from '../../services/RemoteSessionController'; -import { RemoteSessionManager } from '../../services/RemoteSessionManager'; -import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; -import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -import { RemoteToolActionController } from '../../services/RemoteToolActionController'; -import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; -import { QrScanService } from '../../services/QrScanService'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../../services/VoiceInputLifecycleController'; -import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent } from '../components/ConversationIntent'; -import { AppRootHostPort } from '../host/AppRootHostAdapter'; -import { - AppRootPresentation, - AppRootPresentationActions, - ConnectPresentationActions, - FilePreviewPresentationActions, - RemoteCreatePresentationActions, - RemoteHomePresentationActions, - SettingsPresentationActions, - SidebarPresentationActions -} from '../components/AppRootPresentation'; -import { - AppNavigationBackAction, - AppRoute, - AppRouteContract, - ConversationSource -} from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; -import { AppShellViewModel } from './AppShellViewModel'; -import { - RemoteActivityViewModel, - RemoteActivityViewModelHooks -} from './RemoteActivityViewModel'; -import { - RemoteConnectionViewModel -} from './RemoteConnectionViewModel'; -import { - ConversationIntentDispatcher, - ConversationIntentDispatcherHooks -} from './ConversationIntentDispatcher'; -import { GeneralChatPageState } from './GeneralChatPageState'; -import { RemotePageState } from './RemotePageState'; -import { RemoteCreateSessionState } from './RemoteCreateSessionState'; -import { ConversationViewModel } from './ConversationViewModel'; -import { FilePreviewState } from './FilePreviewState'; -import { FilePreviewRequest, FilePreviewTargetContext } from './FilePreviewTarget'; -import { - RemoteWorkspaceViewModel, - RemoteWorkspaceViewModelHooks -} from './RemoteWorkspaceViewModel'; -import { - RemoteSessionViewModel, - RemoteSessionViewModelHooks -} from './RemoteSessionViewModel'; -import { - GeneralChatConversationViewModel, - GeneralChatConversationViewModelHooks -} from './GeneralChatConversationViewModel'; -import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; - -enum ConnectionState { - Idle = 'idle', - Parsing = 'parsing', - Pairing = 'pairing', - Connected = 'connected', - Reconnecting = 'reconnecting', - Failed = 'failed', - Disconnected = 'disconnected' -} - -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; - -export class AppRootRuntime { - readonly host: AppRootHostPort; - - constructor(host: AppRootHostPort) { - this.host = host; - } - - readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); - readonly workspaceRepository: RemoteWorkspaceRepository = - new RemoteWorkspaceRepository(this.sessionManager); - readonly workspaceCoordinator: RemoteWorkspaceCoordinator = - new RemoteWorkspaceCoordinator(this.workspaceRepository); - readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly filePreviewState: FilePreviewState = new FilePreviewState(); - private controlTargetEpoch: number = 1; - private remoteCreateWorkspaceLoadVersion: number = 0; - readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); - readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); - readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); - private cloudAccountSession?: CloudAccountSession; - private cloudAccountRelayUrl: string = ''; - readonly clipboardService: ClipboardService = new ClipboardService(); - readonly qrScanService: QrScanService = new QrScanService(); - readonly imagePickerService: ImagePickerService = new ImagePickerService(); - readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); - readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = - new RemoteConnectionCoordinator( - this.sessionManager, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionGate - ); - readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.visibleGeneralChatDraftId() - ); - readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); - readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.resetGeneralChatTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.syncGeneralChatTimelineFromStore(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.showHomeToast(statusText); - } - } - ); - readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.applyGeneralChatConfig(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); - readonly voiceInputService: VoiceInputService = new VoiceInputService(); - readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.checkConnectionHealth(); - }); - readonly remoteActivityViewModel: RemoteActivityViewModel = - new RemoteActivityViewModel( - this.remoteActivityLifecycleController, - this.remoteConnectionCoordinator, - this.remoteResumeGate, - new RemoteActivityViewModelHooks( - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (): boolean => this.hasRemoteBindingForResume(), - (): boolean => this.isRoute(AppRoute.RemoteChat), - (): SessionSummary => this.activeSession, - (state: string): void => this.setRemoteConnectionState(state as ConnectionState), - (status: string): void => this.setRemoteStatusText(status), - async (err: Object): Promise => this.handleRemoteConnectionError(err), - (): void => this.stopHeartbeat(), - (): void => this.startPolling(), - (): void => this.stopPolling(), - async (): Promise => { - await this.pollActiveSession(); - }, - async (): Promise => { - await this.reconnectActiveRemote(); - }, - async (session: SessionSummary): Promise => { - this.applyRemoteActiveSession(session); - await this.loadActiveMessages(); - } - ) - ); - isSyncingAfterTurn: boolean = false; - knownPollVersion: number = 0; - knownModelCatalogVersion: number = 0; - knownRemoteMessageCount: number = 0; - readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); - readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); - readonly remotePageState: RemotePageState = new RemotePageState(); - readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); - readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = - new RemoteWorkspaceViewModel( - this.remotePageState, - this.workspaceCoordinator, - new RemoteWorkspaceViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.isBusy, - (isBusy: boolean): void => { - this.setRemoteBusy(isBusy); - }, - (statusText: string): void => { - this.setRemoteStatusText(statusText); - }, - (workspace: WorkspaceInfo): void => { - this.applyWorkspace(workspace); - this.remoteSessionController.clearSessions(); - }, - (sessions: RemoteSession[]): void => { - this.applyDiscoveredWorkspaceSessions(sessions); - }, - async (): Promise => { - await this.refreshSessions(); - }, - (error: Object): void => { - this.failRemoteConnection(error); - } - ) - ); - remoteWorkspaceSessions: RemoteSession[] = []; - readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); - readonly appShellState: AppShellState = this.appShellViewModel.state; - readonly voiceInputLifecycleController: VoiceInputLifecycleController = - new VoiceInputLifecycleController( - this.voiceInputService, - { - currentInputText: (): string => this.visibleChatInput(), - currentStatusText: (): string => this.visibleStatusText(), - onInputText: (routeId: string, text: string) => { - this.setChatInputForRoute(routeId as AppRoute, text); - }, - onListening: (routeId: string, isListening: boolean) => { - this.setVoiceListeningForRoute(routeId as AppRoute, isListening); - }, - onStatusText: (statusText: string) => { - this.setVisibleStatusText(statusText); - }, - onError: (message: string) => { - this.showVoiceInputError(message); - } - } - ); - readonly remoteSessionController: RemoteSessionController = - new RemoteSessionController( - this.sessionManager, - 8, - { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { - return item.workspacePath !== this.workspacePath; - }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onLoading: (isLoading: boolean) => { - this.remotePageState.setLoading(isLoading); - }, - onSessionError: (errorText: string) => { - this.remotePageState.setError(errorText); - }, - onReconnecting: () => { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - }, - onConnected: () => { - this.setRemoteConnectionState(ConnectionState.Connected); - }, - onConnectionFailed: (err: Object) => { - this.failRemoteConnection(err); - }, - onStartHeartbeat: () => { - this.startHeartbeat(); - } - } - ); - readonly remoteChatCommandController: RemoteChatCommandController = - new RemoteChatCommandController( - this.sessionManager, - { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.remotePageState.setHasMoreMessages(hasMoreMessages); - this.syncChatTimelineFromStore(); - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); - }, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - if (turnId.length > 0) { - this.chatTimelineStore.setLocalActiveTurn(turnId); - this.syncChatTimelineFromStore(); - } else if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - this.syncChatTimelineFromStore(); - } - this.nudgeChatPolling(); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - this.remotePageState.setChatInput(rawText); - this.remotePageState.setSelectedImages(images); - this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); - if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - } - this.syncChatTimelineFromStore(); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - this.remoteSessionController.updateSessionTitle(sessionId, title); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onPollRequested: () => { - this.pollActiveSession(); - } - } - ); - readonly remoteFileDownloadController: RemoteFileDownloadController = - new RemoteFileDownloadController( - this.sessionManager, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); - }, - () => { - this.remotePageState.clearDownloadingFilePath(); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteFilePreviewController: RemoteFilePreviewController = - new RemoteFilePreviewController( - this.sessionManager, - this.filePreviewState, - (): boolean => RemoteUiState.canUseRemote(this.connectionState), - (): number => this.controlTargetEpoch - ); - readonly remoteToolActionController: RemoteToolActionController = - new RemoteToolActionController( - this.sessionManager, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - () => { - this.pollActiveSession(); - } - ); - readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = - new RemoteChatPollingLifecycleController( - this.sessionManager, - { - canPoll: (sessionId: string) => { - return this.activeSession.sessionId === sessionId && - this.isRemoteConversationContext(sessionId) && - this.ensureRemoteAvailable(); - }, - onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { - this.applyChatSessionSnapshot(snapshot); - }, - onError: (error: Object) => { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); - } - } - ); - readonly remoteModelController: RemoteModelController = - new RemoteModelController( - this.sessionManager, - this.identityStore, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteSessionViewModel: RemoteSessionViewModel = - new RemoteSessionViewModel( - this.remotePageState, - this.remoteSessionController, - this.remoteChatCommandController, - this.remoteModelController, - this.remoteFileDownloadController, - new RemoteSessionViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (busy: boolean): void => this.setRemoteBusy(busy), - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), - (): void => this.stopPolling(), - (): void => this.startPolling(), - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => this.remoteFileDownloadController.clear(), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - async (sessionId: string): Promise => { - await this.remoteModelController.loadCatalog( - sessionId, - this.ensureRemoteAvailable(), - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadMessages( - sessionId, - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - await this.remoteSessionController.refresh( - this.remotePageState.sessionQuery, - this.remotePageState.sessionFilter, - this.ensureRemoteAvailable(), - this.connectionState === ConnectionState.Connected - ); - }, - async (path: string): Promise => { - await this.selectWorkspace(path); - } - ) - ); - readonly generalChatConversationViewModel: GeneralChatConversationViewModel = - new GeneralChatConversationViewModel( - this.generalChatPageState, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - this.generalChatStreamLifecycleController, - this.chatTimelineStore, - new GeneralChatConversationViewModelHooks( - (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && - this.isGeneralChatVisible(), - (): string => this.currentActiveTurnId(), - (): string => this.latestUserMessageText(), - (): void => this.syncGeneralChatTimelineFromStore(), - (): void => this.generalChatCommandController.refreshSessions() - ) - ); - readonly remoteConnectionViewModel: RemoteConnectionViewModel = - new RemoteConnectionViewModel( - this.remotePageState, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionCoordinator, - this.remoteSessionController, - this.remoteModelController, - this.remoteFileDownloadController, - this.clipboardService, - this.qrScanService, - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - (): void => this.startHeartbeat(), - (): void => this.stopHeartbeat(), - (): void => this.stopPolling(), - async (): Promise => { - await this.loadRecentWorkspacesInBackground(); - }, - (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), - (): void => this.appShellState.setConnectSheetVisible(false), - (): void => this.appShellState.setConnectSheetVisible(true) - ); - readonly conversationIntentDispatcher: ConversationIntentDispatcher = - new ConversationIntentDispatcher(new ConversationIntentDispatcherHooks( - (): void => this.openAppSidebar(), - (): void => this.closeActiveChat(), - (): void => { this.createSession('code'); }, - (): void => this.prepareNewGeneralChat(), - (): RemoteSession => this.activeGeneralChatAsRemoteSession(), - (): string => this.generalChatPageState.activeSession.sessionId, - (): boolean => this.generalChatPageState.isBusy, - (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, - async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { - await this.generalChatCommandController.pinSession(session, pinned, busy); - }, - async (session: RemoteSession): Promise => { await this.archiveHomeSession(session, true); }, - async (session: RemoteSession): Promise => { - await this.deleteHomeSession(session); - this.prepareNewGeneralChat(); - }, - (text: string): void => this.showHomeToast(text), - (): number => this.activeGeneralUploadedFileCount(), - async (): Promise => { await this.stopActiveChatTask(); }, - async (): Promise => { await this.loadOlderMessages(); }, - async (id: string, input?: Object): Promise => { await this.approveTool(id, input); }, - async (id: string): Promise => { await this.rejectTool(id); }, - async (id: string): Promise => { await this.cancelTool(id); }, - async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { - await this.answerQuestion(id, answers); - }, - async (title: string): Promise => { await this.renameVisibleSession(title); }, - async (text: string): Promise => { await this.copyMessage(text); }, - async (text: string): Promise => { await this.retryVisibleMessage(text); }, - async (id: string): Promise => { await this.selectModel(id); }, - async (): Promise => { await this.pickImages(); }, - (id: string): void => this.removeSelectedImage(id), - (route: AppRoute, request: FilePreviewRequest): void => this.openFilePreview(route, request), - (path: string): void => this.downloadVisibleFile(path), - async (): Promise => { await this.sendVisibleChatMessage(); }, - async (): Promise => { await this.toggleVoiceInput(); }, - (route: AppRoute, value: string): void => this.onVisibleChatInputChange(route, value) - )); - readonly presentationActions: AppRootPresentationActions = new AppRootPresentationActions( - (route: AppRoute): boolean => this.handleNavigationBack(route), - (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), - (): void => this.closeAppSidebar(), - (source: ConversationSource): void => { this.switchWideConversationSource(source); }, - (source: ConversationSource): void => { this.switchCompactConversationSource(source); }, - (): void => this.enterCompactLayout(), - new RemoteHomePresentationActions( - (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), - (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, - (): void => { this.showRecentWorkspaces(); }, (): void => { this.showAssistants(); }, - (path: string): void => { this.selectWorkspace(path); }, (path: string): void => { this.selectAssistant(path); }, - (): void => this.remotePageState.setWorkspacePickerVisible(false), - (): void => this.remotePageState.setAssistantPickerVisible(false), - (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, - (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, - (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, - (agentType: string): void => { this.createSession(agentType); }, - (agentType: string): void => { this.createSession(agentType, true); }, - (): void => { this.openRemoteCreateSession(); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType, true); }, - (session: RemoteSession): void => this.openHomeSession(session), - (session: RemoteSession): void => this.openHomeSessionInPlace(session), - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new RemoteCreatePresentationActions( - (): void => this.closeRemoteCreateSession(), - (): void => { this.toggleRemoteCreateDevices(); }, - (): void => { this.toggleRemoteCreateWorkspaces(); }, - (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, - (path: string): void => this.selectRemoteCreateWorkspace(path), - (value: string): void => this.remoteCreateState.setDraft(value), - async (): Promise => { await this.toggleVoiceInput(); }, - (modelId: string): void => this.selectRemoteCreateModel(modelId), - (): void => { this.submitRemoteCreateSession(); } - ), - new SidebarPresentationActions( - (): void => this.closeAppSidebar(), - (): void => { this.closeAppSidebar(); this.prepareNewGeneralChat(); }, - (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, - (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, - (): void => { - this.closeAppSidebar(); - setTimeout(() => this.appShellState.openSettings('account'), 180); - }, - (session: RemoteSession): void => { this.closeAppSidebar(); this.openHomeSession(session); }, - (session: RemoteSession, archived: boolean): void => { this.archiveHomeSession(session, archived); }, - (session: RemoteSession): void => { this.exportHomeSession(session); }, - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new SettingsPresentationActions( - (): void => this.appShellState.leaveSettings(), - (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, - (): void => { this.reconnect(); }, - (): void => { - this.appShellState.openSettings('account'); - }, - (relayUrl: string, username: string, password: string): Promise => - this.loginCloudAccount(relayUrl, username, password), - (): Promise => this.syncCloudAccount(), - (): Promise => this.logoutCloudAccount(), - (): Promise => this.listCloudAccountDevices(), - (): Promise => this.getRemotePermissionMode(), - (mode: RemotePermissionMode): Promise => this.setRemotePermissionMode(mode), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.testGeneralChatConfig(url, key, model, clear), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.saveGeneralChatConfig(url, key, model, clear) - ), - new ConnectPresentationActions( - (): void => this.appShellState.setConnectSheetVisible(false), - (password?: string): void => { - // Keep connection progress on the same RemoteHome surface as the - // connected state instead of showing a separate loading sheet. - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - this.connect(false, password || ''); - }, - (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, - (url: string): void => { this.setRemoteUrl(url); this.applyRemotePairingProjection(url); }, - (user: string): void => this.setRemoteUserId(user), - (url: string): boolean => this.handleDetectedRemoteUrl(url), - (visible: boolean): void => this.setRemoteUrlInputVisible(visible), - (): void => { this.pasteRemoteUrl(); }, (): void => { this.scanRemoteUrl(); }, - (): Promise => this.listCloudAccountDevices(), - (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) - ), - new FilePreviewPresentationActions( - (): void => this.closeFilePreview(), - (): void => this.refreshFilePreview(), - (path: string): void => this.downloadVisibleFile(path), - (reference: string, label: string): void => this.openFilePreviewLink(reference, label) - ), - (): string => this.generalChatHomeStatusText() - ); - readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; - - - get remoteUrl(): string { return this.remotePageState.remoteUrl; } - get userId(): string { return this.remotePageState.userId; } - get authenticatedUserId(): string { return this.remotePageState.authenticatedUserId; } - get statusText(): string { return this.remotePageState.statusText; } - get connectionState(): ConnectionState { return this.remotePageState.connectionState as ConnectionState; } - get connectionFailureKind(): string { return this.remotePageState.connectionFailureKind; } - get isBusy(): boolean { return this.remotePageState.isBusy; } - get showRemoteUrlInput(): boolean { return this.remotePageState.showRemoteUrlInput; } - get workspaceName(): string { return this.remotePageState.workspaceName; } - get workspacePath(): string { return this.remotePageState.workspacePath; } - get workspaceBranch(): string { return this.remotePageState.workspaceBranch; } - get workspaceKind(): string { return this.remotePageState.workspaceKind; } - get assistantId(): string { return this.remotePageState.assistantId; } - get desktopName(): string { return this.remotePageState.desktopName; } - get desktopId(): string { return this.remotePageState.desktopId; } - get activeSession(): SessionSummary { return this.remotePageState.activeSession; } - get messages(): ChatMessage[] { return this.remotePageState.persistedMessages; } - get pendingMessages(): ChatMessage[] { return this.remotePageState.optimisticMessages; } - get activeTurnMessage(): ChatMessage { return this.remotePageState.activeTurnMessage; } - get timelineItems(): ChatTimelineItem[] { return this.remotePageState.timelineItems; } - get hasMoreMessages(): boolean { return this.remotePageState.hasMoreMessages; } - - async aboutToAppear(): Promise { - this.syncRemotePageSummary(); - await this.generalChatBootstrapController.restore(this.host.context()); - await this.cloudAccountSessionStore.init(this.host.context()); - await this.restoreCloudAccountSession(); - await this.refreshGeneralChatModelCatalog(); - await this.restoreIdentity(); - } - - onPageShow(): void { - RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); - this.resumeRemoteActivity(); - } - - onPageHide(): void { - RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - } - - aboutToDisappear(): void { - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); - this.remoteFileDownloadController.cancel(); - this.remoteFilePreviewController.close(); - this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { - this.setAllVoiceListening(false); - }); - } - - currentRoute(): AppRoute { - return this.appShellViewModel.currentRoute(); - } - - isGeneralComposerRoute(route: AppRoute): boolean { - return AppRootRouteState.isGeneralComposerRoute(route); - } - - visibleChatInput(): string { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.draft; - } - return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleSelectedImages(): SelectedImageAttachment[] { - return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleVoiceListening(): boolean { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.isVoiceListening; - } - return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - setChatInputForRoute(route: AppRoute, value: string): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.setDraft(value); - return; - } - AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); - } - - setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - removeSelectedImageForRoute(route: AppRoute, imageId: string): void { - AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); - } - - clearComposerForRoute(route: AppRoute): void { - AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); - } - - setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.isVoiceListening = isVoiceListening; - return; - } - AppRootRouteState.setVoiceListening( - route, - isVoiceListening, - this.generalChatPageState, - this.remotePageState - ); - } - - setAllVoiceListening(isVoiceListening: boolean): void { - this.generalChatPageState.setVoiceListening(isVoiceListening); - this.remotePageState.setVoiceListening(isVoiceListening); - } - - voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { - if (route === AppRoute.RemoteCreate) { - return { - routeId: `${route}`, - isListening: this.remoteCreateState.isVoiceListening, - isBusy: this.remoteCreateState.isSubmitting, - inputText: this.remoteCreateState.draft, - selectedImageCount: 0 - }; - } - return AppRootRouteState.snapshot( - route, - this.visibleChatBusy(), - this.generalChatPageState, - this.remotePageState - ); - } - - isRoute(route: AppRoute): boolean { - return this.appShellViewModel.isRoute(route); - } - - isGeneralChatVisible(): boolean { - return this.appShellViewModel.isGeneralChatVisible(); - } - - pushRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.pushRoute(route, sessionId); - } - - replaceRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.replaceRoute(route, sessionId); - } - - popRoute(fallback: AppRoute): void { - this.appShellViewModel.popRoute(fallback); - } - - private routeCreatedRemoteSession(sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteCreate)) { - this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } - this.pushRoute(AppRoute.RemoteChat, sessionId); - } - - private routeRemoteSessionInPlace(_sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat)) { - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - - private isRemoteConversationContext(sessionId: string): boolean { - if (sessionId.length === 0 || this.activeSession.sessionId !== sessionId) { - return false; - } - return this.isRoute(AppRoute.RemoteChat) || this.isRoute(AppRoute.RemoteHome); - } - - handleNavigationBack(route: AppRoute): boolean { - if (this.filePreviewState.visible) { - this.closeFilePreview(); - return true; - } - const action = this.appShellViewModel.backAction(route); - if (action === AppNavigationBackAction.CloseSidebar) { - this.closeAppSidebar(); - return true; - } - if (action === AppNavigationBackAction.CloseActiveChat) { - this.closeActiveChat(); - return true; - } - if (action === AppNavigationBackAction.PopRemoteHome) { - this.popRoute(AppRoute.ChatHome); - return true; - } - return false; - } - - handleRootBack(): boolean { - if (!this.filePreviewState.visible) { - return false; - } - this.closeFilePreview(); - return true; - } - - - handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { - this.conversationIntentDispatcher.dispatch(route, intent); - } - - - async saveGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (!update.clearApiKey) { - const probeError = await this.probeGeneralChatConfig(update); - if (probeError.length > 0) { - return probeError; - } - } - const catalogBeforeSave = await this.generalChatConfigStore.modelCatalog(); - const snapshot = await this.generalChatConfigStore.save(update); - if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { - await this.generalChatConfigStore.selectLocalModel(); - } - this.applyGeneralChatConfig(snapshot); - await this.refreshGeneralChatModelCatalog(); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - async testGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (update.clearApiKey) { - return RemoteI18n.t('settings.modelService.testNeedsKey'); - } - return await this.probeGeneralChatConfig(update); - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async validateGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const snapshot = await this.generalChatConfigStore.snapshot(); - return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); - } - - private async probeGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const apiKey = await this.effectiveGeneralChatApiKey(update); - if (apiKey.length === 0) { - return RemoteI18n.t('settings.modelService.apiKeyRequired'); - } - try { - await ModelProviderGeneralChatAdapter.probeConfiguration(update.apiUrl, apiKey, update.modelName); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async effectiveGeneralChatApiKey(update: GeneralChatConfigUpdate): Promise { - const directKey = update.apiKey.trim(); - if (directKey.length > 0) { - return directKey; - } - if (update.clearApiKey) { - return ''; - } - return (await this.generalChatConfigStore.accessToken()).trim(); - } - - applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { - this.generalChatPageState.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - private async refreshGeneralChatModelCatalog(): Promise { - const catalog = await this.generalChatConfigStore.modelCatalog(); - const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; - this.generalChatPageState.setModelCatalog(catalog, selectedModelId); - const active = await this.generalChatConfigStore.activeSnapshot(); - this.generalChatPageState.setServiceState( - GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) - ); - } - - async restoreIdentity(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - return; - } - await this.remoteConnectionViewModel.restore(this.host.context()); - } - - async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - await this.remoteConnectionViewModel.connect(autoReconnect, accountPassword); - await this.persistDelegatedAccountSession(); - } - - private async persistDelegatedAccountSession(): Promise { - if (this.cloudAccountSession) { - return; - } - const delegated = this.sessionManager.delegatedAccountSession(); - if (!delegated) { - return; - } - this.cloudAccountSession = delegated.session; - this.cloudAccountRelayUrl = delegated.relayUrl; - await this.cloudAccountSessionStore.save({ - relayUrl: delegated.relayUrl, - username: delegated.session.userId, - token: delegated.session.token, - userId: delegated.session.userId, - masterKey: Encoding.bytesToBase64(delegated.session.masterKey) - }); - this.remotePageState.setAccountUserId(delegated.session.userId); - this.remotePageState.setAccountUsername(delegated.session.userId); - RemoteLogger.info('delegated account session persisted after room pairing'); - } - - async reconnect(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - await this.restoreCloudTarget( - this.remotePageState.controlTargetDeviceId, - this.remotePageState.controlTargetDeviceName - ); - return; - } - await this.remoteConnectionViewModel.reconnect(); - } - - async disconnect(clearPairing: boolean): Promise { - this.invalidateFilePreviewTarget(); - await this.remoteConnectionViewModel.disconnect(clearPairing); - } - - async pasteRemoteUrl(): Promise { - await this.remoteConnectionViewModel.paste(); - } - - async scanRemoteUrl(): Promise { - await this.remoteConnectionViewModel.scan(this.host.context()); - } - - handleDetectedRemoteUrl(remoteUrl: string): boolean { - return this.remoteConnectionViewModel.handleDetectedUrl(remoteUrl); - } - - applyWorkspace(workspace: WorkspaceInfo): void { - this.remoteConnectionViewModel.applyWorkspace(workspace); - } - - applyRemotePairingProjection(remoteUrl: string): void { - this.remoteConnectionViewModel.projectRemoteUrl(remoteUrl); - } - - ensureRemoteAvailable(): boolean { - return this.remoteConnectionViewModel.ensureAvailable(); - } - - setRemoteConnectionState(connectionState: ConnectionState): void { - this.remotePageState.setConnectionState(connectionState); - } - - setRemoteUrl(remoteUrl: string): void { - this.remotePageState.setRemoteUrl(remoteUrl); - } - - setRemoteUserId(userId: string): void { - this.remotePageState.setUserId(userId); - } - - setRemoteAuthenticatedUserId(authenticatedUserId: string): void { - this.remotePageState.setAuthenticatedUserId(authenticatedUserId); - } - - setRemoteStatusText(statusText: string): void { - this.remotePageState.setStatusText(statusText); - } - - setRemoteConnectionFailureKind(connectionFailureKind: string): void { - this.remotePageState.setConnectionFailureKind(connectionFailureKind); - } - - setRemoteBusy(isBusy: boolean): void { - this.remotePageState.setBusy(isBusy); - } - - setRemoteUrlInputVisible(visible: boolean): void { - this.remotePageState.setRemoteUrlInputVisible(visible); - } - - syncRemotePageSummary(): void { - if (this.remotePageState.statusText.length === 0) { - this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); - } - if (this.remotePageState.workspaceName.length === 0) { - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - } - } - - failRemoteConnection(err: Object): void { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - this.stopHeartbeat(); - } - - async showRecentWorkspaces(): Promise { - await this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); - } - - async showAssistants(): Promise { - await this.remoteWorkspaceViewModel.toggleAssistants(); - } - - async selectWorkspace(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectWorkspace(path); - } - - async selectAssistant(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectAssistant(path); - } - - async refreshSessions(): Promise { - await this.remoteSessionViewModel.refreshSessions(); - } - - async loadMoreSessions(): Promise { - await this.remoteSessionViewModel.loadMoreSessions(); - } - - setSessionFilter(filter: string): void { - this.remoteSessionViewModel.setFilter(filter); - } - - visibleChatBusy(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.isBusy : this.remotePageState.isBusy; - } - - visibleStatusText(): string { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.statusText : this.remotePageState.statusText; - } - - setVisibleStatusText(statusText: string): void { - if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { - this.generalChatPageState.setStatus(statusText); - return; - } - this.setRemoteStatusText(statusText); - } - - openAppSidebar(): void { - this.host.animate(230, () => { - this.appShellState.setSidebarVisible(true); - }); - } - - closeAppSidebar(): void { - this.host.animate(210, () => { - this.appShellState.setSidebarVisible(false); - }); - } - - enterCodeEntry(): void { - if (this.cloudAccountSession && this.remotePageState.accountUserId.trim().length > 0) { - this.appShellState.setConnectSheetVisible(true); - return; - } - if (RemoteUiState.canUseRemote(this.connectionState)) { - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - - async switchWideConversationSource(source: ConversationSource): Promise { - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = this.remotePageState.activeSession.sessionId || ''; - const target = AppRouteContract.routeForConversationSource( - source, - RemoteUiState.canUseRemote(this.connectionState), - activeRemoteSessionId - ); - const hasActiveRemoteConversation = target.name === AppRoute.RemoteChat; - this.appShellViewModel.replaceRouteWithoutAnimation( - hasActiveRemoteConversation ? AppRoute.RemoteHome : target.name - ); - if (hasActiveRemoteConversation) { - this.startPolling(); - await this.loadActiveMessages(); - } - } - - /** - * Compact counterpart of switchWideConversationSource. Switching source is a - * change of context, not a command to start something: it resumes the session - * the user was last in, and otherwise rests on the Remote landing surface - * rather than opening the create composer for them. - */ - async switchCompactConversationSource(source: ConversationSource): Promise { - this.closeAppSidebar(); - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = RemoteUiState.canUseRemote(this.connectionState) ? - (this.remotePageState.activeSession.sessionId || '') : ''; - if (activeRemoteSessionId.length === 0) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); - this.startPolling(); - await this.loadActiveMessages(); - } - - private enterCompactLayout(): void { - const sessionId = this.remotePageState.activeSession.sessionId || ''; - if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { - this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); - } - } - - openAddConnection(): void { - this.appShellState.setConnectSheetVisible(true); - } - - openRemoteControlSettings(): void { - setTimeout(() => { - this.appShellState.openSettings('remote'); - }, 180); - } - - openAddConnectionFromSettings(): void { - this.appShellState.setSettingsVisible(false); - setTimeout(() => { - this.openAddConnection(); - }, 220); - } - - async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { - RemoteLogger.info('cloud account UI login requested'); - const session = await this.cloudAccountClient.login(relayUrl, username, password, this.identityStoreSnapshotInstallId()); - this.applyCloudAccountSession(session, relayUrl, username); - await this.cloudAccountSessionStore.save({ - relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }); - await this.loadGeneralChatAccountModels(session, relayUrl); - RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); - RemoteLogger.info(`cloud account login success user=${session.userId}`); - return session.userId; - } - - private async restoreCloudAccountSession(): Promise { - try { - const persisted = await this.cloudAccountSessionStore.load(); - if (!persisted) return; - const session: CloudAccountSession = { - token: persisted.token, - userId: persisted.userId, - masterKey: Encoding.base64ToBytes(persisted.masterKey) - }; - this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); - await this.loadGeneralChatAccountModels(session, persisted.relayUrl); - } catch (err) { - RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - await this.cloudAccountSessionStore.clear(); - } - } - - private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { - this.generalChatConfigStore.replaceAccountModels([]); - try { - const blob = await this.cloudAccountClient.fetchSettings(relayUrl, session); - if (!blob) { - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info('cloud model catalog is empty'); - return; - } - const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); - this.generalChatConfigStore.replaceAccountModels(models); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); - } catch (err) { - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - async syncCloudAccount(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - let bundles: Object[]; - try { - bundles = await this.cloudAccountClient.fetchSessions(this.cloudAccountRelayUrl, this.cloudAccountSession, 0); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); - } - await this.loadGeneralChatAccountModels(this.cloudAccountSession, this.cloudAccountRelayUrl); - RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); - return String(bundles.length); - } - - protected applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.cloudAccountSession = session; - this.cloudAccountRelayUrl = relayUrl.trim(); - this.remotePageState.setAccountUserId(session.userId); - this.remotePageState.setAccountUsername(username.trim()); - } - - async logoutCloudAccount(): Promise { - this.invalidateFilePreviewTarget(); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - this.remotePageState.setAuthenticatedUserId(''); - } - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - this.remotePageState.clearControlTarget(); - RemoteLogger.info('cloud account logout success'); - } - - async listCloudAccountDevices(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - return []; - } - try { - return await this.cloudAccountClient.listDevices(this.cloudAccountRelayUrl, this.cloudAccountSession); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - if (err instanceof CloudAccountRequestError && - (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { - throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); - } - } - - async getRemotePermissionMode(): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.getPermissionMode(); - } - - async setRemotePermissionMode(mode: RemotePermissionMode): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.setPermissionMode(mode); - } - - private async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { - const targetId = targetDeviceId.trim(); - if (targetId.length === 0) { - return; - } - try { - const devices = await this.listCloudAccountDevices(); - const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); - if (!target || !target.online) { - const targetName = target?.deviceName || targetDeviceName || targetId; - this.remotePageState.setControlTarget('account_device', targetId, targetName); - this.remotePageState.setDesktopIdentity(targetName, targetId); - this.remotePageState.setConnectionState(ConnectionState.Failed); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); - return; - } - await this.selectCloudAccountDevice({ - deviceId: target.deviceId, - deviceName: target.deviceName || targetDeviceName || target.deviceId, - online: target.online, - lastSeenAt: target.lastSeenAt - }); - } catch (err) { - RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - private async expireCloudAccountSession(): Promise { - this.invalidateFilePreviewTarget(); - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - } - } - - private async handleRemoteConnectionError(err: Object): Promise { - if (this.remotePageState.controlTargetType !== 'account_device' || - !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { - return false; - } - await this.expireCloudAccountSession(); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); - return true; - } - - async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { - if (!device.online) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - const deviceId = device.deviceId.trim(); - if (deviceId.length === 0 || deviceId === this.remoteConnectionViewModel.getDeviceId()) { - return; - } - if (deviceId === this.remotePageState.controlTargetDeviceId && - this.connectionState === ConnectionState.Connected) { - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - return; - } - this.invalidateFilePreviewTarget(); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - // Do not keep presenting the previous device while the new account device - // is being handshaken. Clear its projection before the async connect. - this.remotePageState.setConnectionState(ConnectionState.Reconnecting); - this.remotePageState.setLoadingHome(true); - this.remotePageState.clearControlTarget(); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setBusy(true); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); - this.remotePageState.clearActiveSession(); - this.resetChatTimeline(''); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.remotePageState.setSessions([], false); - try { - const initialSync = await this.sessionManager.connectAccountDevice( - this.cloudAccountClient, - this.cloudAccountRelayUrl, - this.cloudAccountSession, - deviceId - ); - this.remotePageState.setControlTarget('account_device', deviceId, device.deviceName); - this.remotePageState.setDesktopIdentity(device.deviceName, deviceId); - this.remotePageState.setWorkspace( - initialSync.workspace.name, - initialSync.workspace.path, - initialSync.workspace.assistantId || '', - initialSync.workspace.gitBranch, - initialSync.workspace.workspaceKind || 'normal' - ); - this.remotePageState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.remotePageState.setAuthenticatedUserId(initialSync.authenticatedUserId); - this.remotePageState.setConnectionState(ConnectionState.Connected); - this.remotePageState.setStatusText(RemoteI18n.t('connection.connected')); - this.appShellState.setSettingsVisible(false); - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - await this.cloudAccountSessionStore.save({ - relayUrl: this.cloudAccountRelayUrl, - username: this.remotePageState.accountUsername, - token: this.cloudAccountSession.token, - userId: this.cloudAccountSession.userId, - masterKey: Encoding.bytesToBase64(this.cloudAccountSession.masterKey), - targetDeviceId: deviceId, - targetDeviceName: device.deviceName - }); - this.startHeartbeat(); - await this.loadRecentWorkspacesInBackground(); - this.remotePageState.setLoadingHome(false); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - } - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Failed); - const message = ConnectionErrorPolicy.errorText(err); - this.remotePageState.setStatusText(message); - this.sessionManager.reset(); - throw new Error(message); - } finally { - this.remotePageState.setLoadingHome(false); - this.remotePageState.setBusy(false); - } - } - - private identityStoreSnapshotInstallId(): string { - return this.remoteConnectionViewModel.getDeviceId(); - } - - openHomeSession(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session); - } - - openHomeSessionInPlace(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session, true); - } - - async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteSession(session); - return; - } - await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); - } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.generalChatPageState.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.generalChatPageState.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - activeGeneralUploadedFileCount(): number { - let count = 0; - this.generalChatPageState.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; - } - - async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - await this.generalChatCommandController.exportSession( - session, - this.generalChatPageState.isBusy, - async (text: string): Promise => { - await this.clipboardService.writeText(text); - } - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - if (this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - await this.generalChatCommandController.openSession( - item, - this.generalChatPageState.isBusy, - async (sessionId: string): Promise => { - return this.generalChatDraftLifecycleController.restore(sessionId); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - } - - async startGeneralChat(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0 || this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - this.generalChatDraftLifecycleController.cancel(); - const created = await this.generalChatCommandController.createSession( - trimmed, - this.generalChatPageState.isBusy, - async (): Promise => { - await this.generalChatDraftLifecycleController.clearHomeNow(); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - if (!created) { - return; - } - await this.sendGeneralChatMessage(); - } - - async sendVisibleChatMessage(): Promise { - if (this.isGeneralChatVisible()) { - if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await this.sendGeneralChatMessage(); - return; - } - await this.sendChatMessage(); - } - - async stopActiveChatTask(): Promise { - if (this.isGeneralChatVisible()) { - this.stopGeneralChatStream(true); - return; - } - await this.stopActiveTask(); - } - - closeActiveChat(): void { - this.closeFilePreview(); - this.stopVoiceInput(false); - if (this.isRoute(AppRoute.GeneralChat)) { - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true); - this.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - this.stopPolling(); - this.popRoute(AppRoute.RemoteHome); - } - - async renameVisibleSession(title: string): Promise { - if (this.isGeneralChatVisible()) { - await this.generalChatCommandController.renameActiveSession( - this.generalChatPageState.activeSession, - title - ); - return; - } - await this.renameActiveSession(title); - } - - async retryVisibleMessage(text: string): Promise { - if (this.isGeneralChatVisible()) { - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - const prepared = await this.generalChatCommandController.retryMessage( - sessionId, - text, - this.generalChatPageState.isBusy - ); - if (prepared) { - await this.sendGeneralChatMessage(); - } - return; - } - this.retryMessage(text); - } - - downloadVisibleFile(path: string): void { - if (this.isGeneralChatVisible()) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadFile(path); - } - - openFilePreview(route: AppRoute, request: FilePreviewRequest): void { - const context = new FilePreviewTargetContext( - this.remotePageState.activeSession.sessionId, - this.remotePageState.activeSession.workspacePath || this.remotePageState.workspacePath, - this.controlTargetEpoch - ); - const resolution = FileTargetResolver.resolve(request.reference, request.label, context); - if (resolution.kind === FileReferenceKind.HttpUrl) { - void this.openExternalLink(route, request.reference); - return; - } - if (route !== AppRoute.RemoteChat) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); - return; - } - if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { - return; - } - void this.remoteFilePreviewController.open(resolution.target); - } - - private async openExternalLink(route: AppRoute, reference: string): Promise { - const opened = this.host.openExternalLink ? await this.host.openExternalLink(reference) : false; - if (!opened) { - if (AppRouteContract.isGeneralComposerRoute(route)) { - this.generalChatPageState.setStatus(RemoteI18n.t('errors.operationFailed')); - } else { - this.setRemoteStatusText(RemoteI18n.t('errors.operationFailed')); - } - } - } - - closeFilePreview(): void { - this.remoteFilePreviewController.close(); - } - - refreshFilePreview(): void { - void this.remoteFilePreviewController.refresh(); - } - - openFilePreviewLink(reference: string, label: string): void { - this.openFilePreview(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); - } - - invalidateFilePreviewTarget(): void { - this.controlTargetEpoch += 1; - this.remoteFilePreviewController.close(); - } - - async createSession(agentType: string, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSession( - agentType, - '', - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - openRemoteCreateSession(): void { - if (!this.ensureRemoteAvailable()) { - return; - } - const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); - if (deviceId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); - this.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - closeRemoteCreateSession(): void { - this.remoteCreateWorkspaceLoadVersion += 1; - this.stopVoiceInput(false); - this.remoteCreateState.closeMenu(); - this.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - if (this.remotePageState.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await this.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog( - catalog, - this.remotePageState.selectedModelId - ); - this.remotePageState.setModelCatalog(catalog, selectedModelId); - this.remoteCreateState.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; - try { - const phoneDeviceId = this.remoteConnectionViewModel.getDeviceId(); - const accountDevices = await this.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0 && !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreateState.setDevices(devices); - } catch (err) { - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreateState.setDevices([]); - } - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreateState.selectedDeviceId; - this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; - try { - const workspaces = await this.workspaceCoordinator.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces(workspaces); - } catch (err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces([]); - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreateState.toggleMenu('devices'); - if (this.remoteCreateState.openMenu === 'devices' && this.remoteCreateState.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreateState.toggleMenu('workspaces'); - if (this.remoteCreateState.openMenu === 'workspaces' && this.remoteCreateState.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - if (device.deviceId === this.remoteCreateState.selectedDeviceId) { - this.remoteCreateState.closeMenu(); - return; - } - const draft = this.remoteCreateState.draft; - this.remoteCreateState.closeMenu(); - this.remoteCreateState.isLoadingWorkspaces = true; - try { - await this.selectCloudAccountDevice(device, false); - this.remoteCreateState.selectDevice(device); - this.remoteCreateState.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreateState.isLoadingWorkspaces = false; - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreateState.workspaces.find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreateState.selectWorkspace(workspace); - } - - selectRemoteCreateModel(modelId: string): void { - this.remoteCreateState.setSelectedModelId(modelId); - } - - async submitRemoteCreateSession(): Promise { - const instruction = this.remoteCreateState.draft.trim(); - if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { - return; - } - const context = this.remoteCreateState.submissionContext(); - const activeDeviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreateState.isSubmitting = true; - this.remoteCreateState.errorText = ''; - this.remoteCreateState.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace( - context.workspacePath, - this.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreateState.selectedModelId - ); - } else { - await this.remoteSessionViewModel.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreateState.selectedModelId - ); - } - if (this.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreateState.isSubmitting = false; - } - } - - async createSessionInWorkspace( - path: string, - agentType: string = 'code', - inPlace: boolean = false - ): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSessionInWorkspace( - path, - this.workspacePath, - '', - agentType, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { - this.remoteWorkspaceSessions = all; - const current = this.remotePageState.sessions; - const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); - } - - async loadRecentWorkspacesInBackground(): Promise { - await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); - } - - mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { - const merged = primary.slice(); - extras.forEach((item: RemoteSession) => { - if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { - merged.push(item); - } - }); - return merged; - } - - async openSession(item: RemoteSession, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.openSession( - item, - this.workspacePath, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyRemoteActiveSession(session: SessionSummary): void { - const current = this.remotePageState.activeSession; - if (this.filePreviewState.visible && - (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { - this.closeFilePreview(); - } - this.remotePageState.setActiveSession(session); - } - - async deleteSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.deleteSession(item, this.workspacePath); - } - - async loadActiveMessages(): Promise { - await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async loadModelCatalog(sessionId: string): Promise { - await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async selectModel(modelId: string): Promise { - if (this.isGeneralChatVisible()) { - if (await this.generalChatConfigStore.selectModel(modelId)) { - await this.refreshGeneralChatModelCatalog(); - } - return; - } - await this.remoteSessionViewModel.selectModel(modelId); - } - - async loadOlderMessages(): Promise { - await this.remoteSessionViewModel.loadOlderMessages(this.knownPollVersion); - } - - async sendGeneralChatMessage(): Promise { - await this.generalChatConversationViewModel.sendMessage(); - return; - } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.generalChatConversationViewModel.stop(cancelled, finalStatus); - return; - } - - async sendChatMessage(): Promise { - if (this.remotePageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.remotePageState.chatInput.trim(); - const images = this.remotePageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); - this.syncChatTimelineFromStore(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); - this.startPolling(); - this.nudgeChatPolling(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; - await this.remoteChatCommandController.sendPreparedMessage( - sessionId, - text, - this.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.isBusy, - true - ); - } - - async toggleVoiceInput(): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.toggle(this.host.context(), this.voiceInputSnapshot(route)); - } - - async stopVoiceInput(showStatus: boolean): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); - } - - showVoiceInputError(message: string): void { - const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); - this.setVisibleStatusText(text); - this.host.showToast(text, 2600); - } - - async pickImages(): Promise { - if (this.visibleChatBusy()) { - return; - } - const route = this.currentRoute(); - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - try { - this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); - const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); - if (picked.length === 0) { - this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); - return; - } - this.addSelectedImagesForRoute(route, picked); - this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); - } catch (err) { - this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - removeSelectedImage(imageId: string): void { - this.removeSelectedImageForRoute(this.currentRoute(), imageId); - } - - startVisibleGeneralChat(): void { - const rawText = this.generalChatPageState.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.generalChatPageState.isBusy) { - return; - } - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); - this.generalChatPageState.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || - this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || - this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - this.generalChatPageState.statusText - ); - } - - prepareNewGeneralChat(): void { - this.stopVoiceInput(false); - this.stopGeneralChatStream(true); - this.generalChatDraftLifecycleController.clearHome(); - this.generalChatPageState.clearComposer(); - this.generalChatPageState.clearActiveSession(); - this.resetGeneralChatTimeline(''); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - - onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInputForRoute(route, value); - if (!this.isGeneralComposerRoute(route)) { - return; - } - this.generalChatDraftLifecycleController.scheduleVisible(value); - } - - visibleGeneralChatDraftId(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; - } - return ''; - } - - persistVisibleGeneralChatDraft(): void { - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - } - - async restoreGeneralChatDraft(draftId: string): Promise { - this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); - } - - latestUserMessageText(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.latestUserMessageText(); - } - const candidates = this.messages.concat(this.pendingMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; - } - - showHomeToast(message: string): void { - if (!this.host.showToast(message, 2600)) { - this.setVisibleStatusText(message); - } - } - - async stopActiveTask(): Promise { - const sessionId = this.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await this.remoteChatCommandController.stopTask( - sessionId, - this.activeTurnMessage.id, - this.currentActiveTurnId(), - this.ensureRemoteAvailable() - ); - } - - async renameActiveSession(title: string): Promise { - const nextTitle = title.trim(); - if ( - !this.activeSession.sessionId || - nextTitle.length === 0 || - nextTitle === this.activeSession.title || - this.isBusy - ) { - return; - } - await this.remoteChatCommandController.renameActiveSession( - this.activeSession, - nextTitle, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - async copyMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.clipboardService.writeText(text); - this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - async downloadFile(path: string): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); - } - - retryMessage(text: string): void { - if (this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.setChatInput(text); - this.sendChatMessage(); - } - - async approveTool(toolId: string, updatedInput?: Object): Promise { - await this.remoteToolActionController.approve( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - updatedInput - ); - } - - async rejectTool(toolId: string): Promise { - await this.remoteToolActionController.reject( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async cancelTool(toolId: string): Promise { - await this.remoteToolActionController.cancel( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.remoteToolActionController.answer( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - answers - ); - } - - resetChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.knownPollVersion = 0; - this.syncChatTimelineFromStore(); - } - - resetGeneralChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.syncGeneralChatTimelineFromStore(); - } - - syncChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems: ChatTimelineItem[] = this.projectedTimelineItems(); - this.remotePageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.hasMoreMessages, - projectedItems - ); - this.remotePageState.setModelCatalog(state.modelCatalog, state.selectedModelId); - } - - syncGeneralChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems = this.chatTimelineStore.viewState(false); - this.generalChatPageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - startPolling(): void { - this.remoteChatPollingLifecycleController.startActiveSession({ - sessionId: this.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.activeTurnMessage - }); - } - - stopPolling(): void { - this.remoteChatPollingLifecycleController.stop(); - } - - nudgeChatPolling(): void { - this.remoteChatPollingLifecycleController.nudge(); - } - - async pollActiveSession(): Promise { - await this.remoteChatPollingLifecycleController.pollNow(); - } - - currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersion, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersion = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.remoteChatPollingLifecycleController.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (!this.isRemoteConversationContext(snapshot.sessionId)) { - return; - } - this.chatTimelineStore.applySnapshot(snapshot); - this.syncChatTimelineFromStore(); - this.knownPollVersion = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.remotePageState.setActiveSession({ - sessionId: this.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }); - } - if (snapshot.modelCatalog) { - this.remoteModelController.applyCatalog(snapshot.modelCatalog); - } - this.setRemoteStatusText(this.hasRunningActiveTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterTurnEnded(); - } - } - - hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - currentActiveTurnId(): string { - const activeTurnMessage = this.isGeneralChatVisible() ? - this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; - } - - projectedTimelineItems(): ChatTimelineItem[] { - return this.chatTimelineStore.viewState(this.hasMoreMessages); - } - - startHeartbeat(): void { - this.remoteActivityViewModel.startHeartbeat(); - } - - stopHeartbeat(): void { - this.remoteActivityViewModel.stopHeartbeat(); - } - - async checkConnectionHealth(): Promise { - await this.remoteActivityViewModel.checkConnectionHealth(); - } - - resumeRemoteActivity(): void { - this.remoteActivityViewModel.resume(); - } - - hasRemoteBindingForResume(): boolean { - if (this.remotePageState.controlTargetType === 'account_device') { - return this.remotePageState.accountUserId.trim().length > 0 && - this.remotePageState.controlTargetDeviceId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Disconnected; - } - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Parsing && - this.connectionState !== ConnectionState.Pairing && - this.connectionState !== ConnectionState.Disconnected; - } - - private async reconnectActiveRemote(): Promise { - if (this.remotePageState.controlTargetType !== 'account_device') { - await this.connect(true); - return; - } - const targetId = this.remotePageState.controlTargetDeviceId; - const device = (await this.listCloudAccountDevices()).find((item: CloudAccountDevice): boolean => item.deviceId === targetId); - if (!device) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - await this.selectCloudAccountDevice(device); - } - - hasRemoteBindingForCodeHome(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - (this.connectionState === ConnectionState.Connected || - this.connectionState === ConnectionState.Reconnecting || - this.connectionState === ConnectionState.Pairing || - this.connectionState === ConnectionState.Parsing); - } - - shortSessionId(sessionId: string): string { - if (sessionId.length <= 8) { - return sessionId; - } - return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - async syncAfterTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - await this.loadActiveMessages(); - } finally { - this.isSyncingAfterTurn = false; - } - } - -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index b9009f449f..e34e22c5d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -12,12 +12,22 @@ export class AppShellState { @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + /** + * Mirror of the resolved master-detail layout mode. Only the presentation + * layer measures the viewport, so runtime logic that must branch on compact + * versus wide reads it from here. + */ + @Trace wideLayout: boolean = false; private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; } + setWideLayout(wide: boolean): void { + this.wideLayout = wide; + } + setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets new file mode 100644 index 0000000000..f86b14dd1c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -0,0 +1,191 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemoteModelConfig, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { RemoteUiState } from '../../services/RemoteUiState'; + +/** Shared observable state for General Chat and Remote Chat conversations. */ +@ObservedV2 +export class ConversationCoreState { + @Trace sessions: RemoteSession[] = []; + @Trace activeSession: SessionSummary; + @Trace persistedMessages: ChatMessage[] = []; + @Trace optimisticMessages: ChatMessage[] = []; + @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); + @Trace hasMoreMessages: boolean = false; + @Trace timelineItems: ChatTimelineItem[] = []; + @Trace timelineRevision: number = 0; + @Trace isBusy: boolean = false; + @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); + @Trace selectedModelId: string = ''; + @Trace statusText: string = ''; + @Trace chatInput: string = ''; + @Trace selectedImages: SelectedImageAttachment[] = []; + @Trace isVoiceListening: boolean = false; + private readonly defaultAgentType: string; + private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + constructor(defaultAgentType: string) { + this.defaultAgentType = defaultAgentType; + this.activeSession = ConversationCoreState.emptySession(defaultAgentType); + } + + setSessions(sessions: RemoteSession[]): void { + this.sessions = sessions.slice(); + } + + setActiveSession(session: SessionSummary): void { + this.activeSession = { + sessionId: session.sessionId, + title: session.title, + workspacePath: session.workspacePath, + agentType: this.defaultAgentType === 'chat' ? 'chat' : session.agentType, + initialTurnId: session.initialTurnId + }; + } + + clearActiveSession(): void { + this.activeSession = ConversationCoreState.emptySession(this.defaultAgentType); + this.clearTimeline(); + } + + setTimelineProjection( + persistedMessages: ChatMessage[], + optimisticMessages: ChatMessage[], + activeTurnMessage: ChatMessage, + hasMoreMessages: boolean, + timelineItems: ChatTimelineItem[] + ): void { + this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); + this.persistedMessages = persistedMessages.slice(); + this.optimisticMessages = optimisticMessages.slice(); + this.activeTurnMessage = activeTurnMessage.id.length > 0 ? + ConversationCoreState.copyMessage(activeTurnMessage) : + RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = hasMoreMessages; + this.timelineItems = timelineItems.slice(); + } + + setHasMoreMessages(hasMoreMessages: boolean): void { + this.hasMoreMessages = hasMoreMessages; + } + + clearTimeline(): void { + this.persistedMessages = []; + this.optimisticMessages = []; + this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = false; + this.timelineItems = []; + this.timelineRevision = this.timelineRevisionTracker.reset(); + } + + setBusy(isBusy: boolean): void { + this.isBusy = isBusy; + } + + setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { + this.modelCatalog = ConversationCoreState.copyModelCatalog(modelCatalog); + this.selectedModelId = selectedModelId; + } + + setStatusText(statusText: string): void { + this.statusText = statusText; + } + + setChatInput(chatInput: string): void { + this.chatInput = chatInput; + } + + setSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = selectedImages.slice(); + } + + addSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = this.selectedImages.concat(selectedImages); + } + + removeSelectedImage(imageId: string): void { + this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + } + + clearComposer(): void { + this.chatInput = ''; + this.selectedImages = []; + } + + setVoiceListening(isVoiceListening: boolean): void { + this.isVoiceListening = isVoiceListening; + } + + hasRunningActiveTurn(): boolean { + return this.activeTurnMessage.id.length > 0 && + (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + latestUserMessageText(): string { + const candidates = this.persistedMessages.concat(this.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + private static emptySession(agentType: string): SessionSummary { + return { + sessionId: '', + title: '', + workspacePath: '', + agentType + }; + } + + private static copyMessage(message: ChatMessage): ChatMessage { + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: message.detail, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools ? message.tools.slice() : undefined, + items: message.items ? message.items.slice() : undefined, + images: message.images ? message.images.slice() : undefined + }; + } + + private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { + return { + version: modelCatalog.version, + models: modelCatalog.models.map((model): RemoteModelConfig => { + return { + id: model.id, + name: model.name, + provider: model.provider, + base_url: model.base_url, + model_name: model.model_name, + context_window: model.context_window, + enabled: model.enabled, + capabilities: model.capabilities.slice(), + reasoning: model.reasoning + }; + }), + default_models: { + primary: modelCatalog.default_models.primary, + fast: modelCatalog.default_models.fast, + search: modelCatalog.default_models.search, + image_understanding: modelCatalog.default_models.image_understanding + }, + session_model_id: modelCatalog.session_model_id + }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index a7db3241bc..a555f25aa0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -18,6 +18,7 @@ import { toConversationUiSession } from '../components/ConversationUiModels'; import { AppRoute } from '../navigation/AppRouteContract'; +import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; @@ -32,6 +33,7 @@ export class ConversationViewState { connectionState: string = 'idle'; composerCapabilities: ChatComposerCapabilities = GENERAL_CHAT_COMPOSER_CAPABILITIES; isBusy: boolean = false; + isLoadingConversation: boolean = false; canStop: boolean = false; hasMoreMessages: boolean = false; timelineItems: ChatTimelineItem[] = []; @@ -63,49 +65,43 @@ export class ConversationViewState { } private static remote(remote: RemotePageState): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(remote.activeSession); + const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; state.desktopName = remote.desktopName; state.workspaceBranch = remote.workspaceBranch; - state.statusText = remote.statusText; state.connectionState = remote.connectionState; + state.isLoadingConversation = remote.isLoadingConversation; state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - state.isBusy = remote.isBusy; - state.canStop = remote.hasRunningActiveTurn(); - state.hasMoreMessages = remote.hasMoreMessages; - state.timelineItems = remote.timelineItems; - state.timelineRevision = remote.timelineRevision; state.showSuggestionsWhenEmpty = false; - state.modelCatalog = toConversationUiModelCatalog(remote.modelCatalog); - state.selectedModelId = remote.selectedModelId; state.downloadingFilePath = remote.downloadingFilePath; state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; - state.selectedImages = remote.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = remote.isVoiceListening; - state.chatInput = remote.chatInput; return state; } private static general(general: GeneralChatPageState, inlineStatus: string): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(general.activeSession); - state.statusText = general.statusText; + const state = ConversationViewState.fromCore(general.conversation); state.inlineStatusText = inlineStatus; state.connectionState = GeneralChatServiceStatus.connectionState(general.serviceState); - state.isBusy = general.isBusy; - state.canStop = general.hasRunningActiveTurn(); - state.hasMoreMessages = general.hasMoreMessages; - state.timelineItems = general.timelineItems; - state.timelineRevision = general.timelineRevision; - state.modelCatalog = toConversationUiModelCatalog(general.modelCatalog); - state.selectedModelId = general.selectedModelId; - state.isSessionPinned = general.activeSession.sessionId.length > 0 && - general.pinnedSessionId() === general.activeSession.sessionId; - state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = general.isVoiceListening; - state.chatInput = general.chatInput; + state.isSessionPinned = general.conversation.activeSession.sessionId.length > 0 && + general.pinnedSessionId() === general.conversation.activeSession.sessionId; + return state; + } + + private static fromCore(core: ConversationCoreState): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(core.activeSession); + state.statusText = core.statusText; + state.isBusy = core.isBusy; + state.canStop = core.hasRunningActiveTurn(); + state.hasMoreMessages = core.hasMoreMessages; + state.timelineItems = core.timelineItems; + state.timelineRevision = core.timelineRevision; + state.modelCatalog = toConversationUiModelCatalog(core.modelCatalog); + state.selectedModelId = core.selectedModelId; + state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = core.isVoiceListening; + state.chatInput = core.chatInput; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets index 88351095ff..7d51e1a0a7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget } from './FilePreviewTarget'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; export enum FilePreviewPhase { Idle = 'idle', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets index 5c4db437fe..a6bcb8bd0f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets @@ -5,50 +5,44 @@ import { SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; -import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; @ObservedV2 export class GeneralChatPageState { - @Trace activeSession: SessionSummary = GeneralChatPageState.emptySession(); - @Trace sessions: RemoteSession[] = []; - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace isBusy: boolean = false; + @Trace conversation: ConversationCoreState = new ConversationCoreState('chat'); @Trace serviceState: GeneralChatServiceState = GeneralChatServiceState.Unconfigured; @Trace apiUrl: string = ''; @Trace modelName: string = ''; @Trace hasApiKey: boolean = false; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; - @Trace statusText: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: 'chat', - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = GeneralChatPageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); } setSessions(sessions: RemoteSession[]): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); } setTimelineProjection( @@ -58,27 +52,21 @@ export class GeneralChatPageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - GeneralChatPageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setConfiguration( @@ -98,46 +86,39 @@ export class GeneralChatPageState { } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = { - version: modelCatalog.version, - models: modelCatalog.models.slice(), - default_models: modelCatalog.default_models, - session_model_id: modelCatalog.session_model_id - }; - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setStatus(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } recentSessions(): RemoteSession[] { - const recent = this.sessions.slice(); + const recent = this.conversation.sessions.slice(); recent.sort((first: RemoteSession, second: RemoteSession) => { if ((first.pinned === true) !== (second.pinned === true)) { return first.pinned === true ? -1 : 1; @@ -148,53 +129,20 @@ export class GeneralChatPageState { } pinnedSessionId(): string { - const pinned = this.sessions.find((session: RemoteSession) => session.pinned === true); + const pinned = this.conversation.sessions.find((session: RemoteSession) => session.pinned === true); return pinned ? pinned.id : ''; } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + return this.conversation.hasRunningActiveTurn(); } latestUserMessageText(): string { - const candidates = this.persistedMessages.concat(this.optimisticMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; + return this.conversation.latestUserMessageText(); } private sessionTimeValue(value: string): number { const parsed = new Date(value).getTime(); return Number.isNaN(parsed) ? 0 : parsed; } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'chat' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index 27e2d77f75..cb77d09d48 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -94,8 +94,18 @@ export class RemoteCreateSessionState { this.errorText = ''; } + /** + * The desktop binds every Claw session to its assistant workspace and ignores + * the requested workspace_path, so a picked workspace only holds when it is + * paired with the code agent. No workspace means the chat option, which is + * what Claw is for. + */ submissionContext(): RemoteCreateSessionContext { - return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + return new RemoteCreateSessionContext( + this.selectedDeviceId, + this.selectedWorkspacePath, + this.selectedWorkspacePath.length > 0 ? 'code' : 'Claw' + ); } clearWorkspace(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 8af7b5da7f..3284ff69fb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -3,13 +3,13 @@ import { ChatMessage, RecentWorkspaceEntry, RemoteModelCatalog, - RemoteModelConfig, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; /** * Observable projection for the Remote home page. @@ -17,6 +17,7 @@ import { RemoteUiState } from '../../services/RemoteUiState'; */ @ObservedV2 export class RemotePageState { + @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); @Trace desktopName: string = ''; @Trace desktopId: string = ''; @Trace remoteUrl: string = ''; @@ -28,10 +29,8 @@ export class RemotePageState { @Trace controlTargetType: string = 'none'; @Trace controlTargetDeviceId: string = ''; @Trace controlTargetDeviceName: string = ''; - @Trace statusText: string = ''; @Trace connectionState: string = 'idle'; @Trace connectionFailureKind: string = ''; - @Trace isBusy: boolean = false; @Trace isLoadingHome: boolean = false; @Trace showRemoteUrlInput: boolean = false; @Trace workspaceName: string = ''; @@ -43,28 +42,32 @@ export class RemotePageState { @Trace assistants: AssistantEntry[] = []; @Trace showWorkspacePicker: boolean = false; @Trace showAssistantPicker: boolean = false; - @Trace sessions: RemoteSession[] = []; - @Trace activeSession: SessionSummary = RemotePageState.emptySession(); - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; @Trace downloadingFilePath: string = ''; @Trace downloadedFilePath: string = ''; @Trace fileDownloadStatus: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; @Trace sessionQuery: string = ''; @Trace sessionFilter: string = 'all'; @Trace hasMoreSessions: boolean = false; @Trace isLoadingSessions: boolean = false; + @Trace isLoadingConversation: boolean = false; + @Trace pendingSessionId: string = ''; + @Trace isConversationDismissed: boolean = false; @Trace sessionErrorText: string = ''; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setQuery(query: string): void { this.sessionQuery = query; @@ -125,7 +128,7 @@ export class RemotePageState { } setStatusText(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setConnectionState(connectionState: string): void { @@ -137,7 +140,7 @@ export class RemotePageState { } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setLoadingHome(isLoadingHome: boolean): void { @@ -184,24 +187,20 @@ export class RemotePageState { } setSessions(sessions: RemoteSession[], hasMore: boolean): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); this.hasMoreSessions = hasMore; this.sessionErrorText = ''; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: session.agentType, - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = RemotePageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.setModelCatalog(RemoteUiState.emptyModelCatalog(), ''); } @@ -212,32 +211,25 @@ export class RemotePageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - RemotePageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } setHasMoreMessages(hasMoreMessages: boolean): void { - this.hasMoreMessages = hasMoreMessages; + this.conversation.setHasMoreMessages(hasMoreMessages); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = RemotePageState.copyModelCatalog(modelCatalog); - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setDownloadStatus(downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void { @@ -257,43 +249,57 @@ export class RemotePageState { } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } setLoading(loading: boolean): void { this.isLoadingSessions = loading; } + setConversationLoading(loading: boolean): void { + this.isLoadingConversation = loading; + } + + setPendingSessionId(sessionId: string): void { + this.pendingSessionId = sessionId; + } + + setConversationDismissed(dismissed: boolean): void { + this.isConversationDismissed = dismissed; + } + setError(errorText: string): void { this.sessionErrorText = errorText; this.isLoadingSessions = false; } clear(): void { - this.sessions = []; + this.conversation.setSessions([]); this.hasMoreSessions = false; this.isLoadingSessions = false; + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.isLoadingHome = false; this.sessionErrorText = ''; } @@ -306,7 +312,7 @@ export class RemotePageState { visibleSessions(): RemoteSession[] { const query = this.sessionQuery.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { + return this.conversation.sessions.filter((item: RemoteSession) => { if (item.id.length === 0 || item.status === 'archived') { return false; } @@ -315,59 +321,6 @@ export class RemotePageState { } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } - - private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { - return { - version: modelCatalog.version, - models: modelCatalog.models.map((model): RemoteModelConfig => { - return { - id: model.id, - name: model.name, - provider: model.provider, - base_url: model.base_url, - model_name: model.model_name, - context_window: model.context_window, - enabled: model.enabled, - capabilities: model.capabilities.slice(), - reasoning: model.reasoning - }; - }), - default_models: { - primary: modelCatalog.default_models.primary, - fast: modelCatalog.default_models.fast, - search: modelCatalog.default_models.search, - image_understanding: modelCatalog.default_models.image_understanding - }, - session_model_id: modelCatalog.session_model_id - }; + return this.conversation.hasRunningActiveTurn(); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets similarity index 98% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 53bf57347e..2b723f14d5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -4,7 +4,7 @@ import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; +import { AppShellState } from '../state/AppShellState'; /** Owns application navigation and global overlay state. */ export class AppShellViewModel { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets new file mode 100644 index 0000000000..b43aa96612 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -0,0 +1,1038 @@ +import { + RecentWorkspaceEntry, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteSession, + SessionSummary, + SelectedImageAttachment +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { + RemoteChatPollingCursor, + RemoteChatPollingLifecycleController, + RemoteChatPollingSnapshot +} from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { AppRootRouteState } from '../navigation/AppRootRouteState'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ConversationViewModel } from './ConversationViewModel'; +import { AppShellViewModel } from './AppShellViewModel'; +import { FilePreviewController } from './FilePreviewController'; +import { RemoteConnectionController } from './RemoteConnectionController'; +import { RemoteSessionViewModel } from './RemoteSessionViewModel'; +import { SettingsController } from './SettingsController'; +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; + +export interface ConversationControllerHooks { + readonly currentRoute: () => AppRoute; +} + +export interface RemoteConversationHooks { + readonly isConversationContext: (sessionId: string) => boolean; + readonly isFilePreviewVisible: () => boolean; + readonly stopVoiceInput: () => Promise; + readonly showToast: (message: string) => boolean; + readonly selectAssistantWorkspace: (path: string) => Promise; +} + +export interface RemoteConversationDependencies { + readonly timeline: ConversationViewModel; + readonly chat: RemoteChatCommandController; + readonly polling: RemoteChatPollingLifecycleController; + readonly models: RemoteModelController; + readonly files: RemoteFileDownloadController; + readonly tools: RemoteToolActionController; + readonly connection: RemoteConnectionController; + readonly imagePicker: ImagePickerService; + readonly clipboard: ClipboardService; + readonly sessions: RemoteSessionViewModel; + readonly sessionManager: RemoteSessionManager; + readonly workspace: RemoteWorkspaceCoordinator; + readonly settings: SettingsController; + readonly appShell: AppShellViewModel; + readonly filePreview: FilePreviewController; + readonly generalCommands: GeneralChatCommandController; + readonly generalConversation: GeneralChatConversationViewModel; + readonly generalDrafts: GeneralChatDraftLifecycleController; + readonly hooks: RemoteConversationHooks; +} + +/** Owns route-dependent composer and voice presentation state. */ +export class ConversationController { + private readonly general: GeneralChatPageState; + private readonly remote: RemotePageState; + private readonly remoteCreate: RemoteCreateSessionState; + private readonly hooks: ConversationControllerHooks; + private readonly remoteRuntime?: RemoteConversationDependencies; + private knownPollVersionValue: number = 0; + private knownModelCatalogVersion: number = 0; + private knownRemoteMessageCount: number = 0; + private isSyncingAfterTurn: boolean = false; + private remoteCreateWorkspaceLoadVersion: number = 0; + + constructor( + general: GeneralChatPageState, + remote: RemotePageState, + remoteCreate: RemoteCreateSessionState, + hooks: ConversationControllerHooks, + remoteRuntime?: RemoteConversationDependencies + ) { + this.general = general; + this.remote = remote; + this.remoteCreate = remoteCreate; + this.hooks = hooks; + this.remoteRuntime = remoteRuntime; + } + + visibleChatInput(): string { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.draft : + AppRootRouteState.chatInput(route, this.general, this.remote); + } + + visibleSelectedImages(): SelectedImageAttachment[] { + return AppRootRouteState.selectedImages(this.hooks.currentRoute(), this.general, this.remote); + } + + visibleVoiceListening(): boolean { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.isVoiceListening : + AppRootRouteState.voiceListening(route, this.general, this.remote); + } + + setChatInput(route: AppRoute, value: string): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.setDraft(value); + return; + } + AppRootRouteState.setChatInput(route, value, this.general, this.remote); + } + + addSelectedImages(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.addSelectedImages(route, images, this.general, this.remote); + } + + removeSelectedImage(route: AppRoute, imageId: string): void { + AppRootRouteState.removeSelectedImage(route, imageId, this.general, this.remote); + } + + setVoiceListening(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.isVoiceListening = isVoiceListening; + return; + } + AppRootRouteState.setVoiceListening(route, isVoiceListening, this.general, this.remote); + } + + clearAllVoiceListening(): void { + this.general.setVoiceListening(false); + this.remote.setVoiceListening(false); + this.remoteCreate.isVoiceListening = false; + } + + visibleBusy(): boolean { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.isBusy : this.remote.isBusy; + } + + visibleStatusText(): string { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.statusText : this.remote.statusText; + } + + setVisibleStatusText(statusText: string): void { + if (this.isGeneralComposerRoute(this.hooks.currentRoute())) { + this.general.setStatus(statusText); + return; + } + this.remote.setStatusText(statusText); + } + + voiceInputSnapshot(route: AppRoute): VoiceInputRouteSnapshot { + if (route === AppRoute.RemoteCreate) { + return { + routeId: `${route}`, + isListening: this.remoteCreate.isVoiceListening, + isBusy: this.remoteCreate.isSubmitting, + inputText: this.remoteCreate.draft, + selectedImageCount: 0 + }; + } + return AppRootRouteState.snapshot(route, this.visibleBusy(), this.general, this.remote); + } + + isGeneralComposerRoute(route: AppRoute): boolean { + return AppRouteContract.isGeneralComposerRoute(route); + } + + knownPollVersion(): number { + return this.knownPollVersionValue; + } + + resetKnownRemoteState(): void { + this.knownPollVersionValue = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + } + + updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { + this.knownRemoteMessageCount = knownMessageCount; + this.updateChatPollingCursor(pollVersion, knownMessageCount); + } + + updateKnownModelCatalogVersion(version: number): void { + this.knownModelCatalogVersion = version; + this.requireRemoteRuntime().polling.updateKnownModelCatalogVersion(version); + } + + async loadRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } + + async loadRemoteModelCatalog(sessionId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.loadCatalog( + sessionId, + runtime.connection.ensureAvailable(), + runtime.hooks.isConversationContext + ); + } + + async selectRemoteModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.selectModel( + modelId, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async loadOlderRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadOlderMessages( + this.remote.activeSession.sessionId || '', + this.knownPollVersionValue, + this.remote.hasMoreMessages, + this.remote.isBusy + ); + } + + async sendRemoteMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.isVoiceListening) { + await runtime.hooks.stopVoiceInput(); + } + const rawText = this.remote.chatInput.trim(); + const images = this.remote.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : + (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.remote.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || + !runtime.connection.ensureAvailable()) { + return; + } + this.remote.clearComposer(); + const localMessage = RemoteUiState.localUserMessage(text, images); + runtime.timeline.appendOptimisticMessage(localMessage); + const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); + this.syncRemoteTimeline(); + RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); + this.startRemotePolling(); + runtime.polling.nudge(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? + runtime.imagePicker.toRemoteContexts(images) : []; + await runtime.chat.sendPreparedMessage( + sessionId, + text, + this.remote.activeSession.agentType, + rawText, + images, + imageContexts, + localMessage.id, + pendingActiveId, + this.remote.isBusy, + true + ); + } + + async stopRemoteTask(): Promise { + const runtime = this.requireRemoteRuntime(); + const sessionId = this.remote.activeSession.sessionId || ''; + if (!sessionId) { + return; + } + await runtime.chat.stopTask( + sessionId, + this.remote.activeTurnMessage.id, + this.remoteActiveTurnId(), + runtime.connection.ensureAvailable() + ); + } + + async renameRemoteSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + const nextTitle = title.trim(); + if (!this.remote.activeSession.sessionId || nextTitle.length === 0 || + nextTitle === this.remote.activeSession.title || this.remote.isBusy) { + return; + } + await runtime.chat.renameActiveSession( + this.remote.activeSession, + nextTitle, + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async copyRemoteMessage(text: string): Promise { + if (text.trim().length === 0) { + return; + } + try { + await this.requireRemoteRuntime().clipboard.writeText(text); + this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); + } catch (err) { + this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async downloadRemoteFile(path: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.files.download( + path, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + retryRemoteMessage(text: string): void { + if (this.remote.isBusy || !this.requireRemoteRuntime().connection.ensureAvailable()) { + return; + } + this.remote.setChatInput(text); + this.sendRemoteMessage(); + } + + async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.approve( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), updatedInput + ); + } + + async rejectRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.reject( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async cancelRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.cancel( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.answer( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers + ); + } + + resetRemoteTimeline(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.timeline.reset(sessionId); + this.knownPollVersionValue = 0; + this.syncRemoteTimeline(); + } + + syncRemoteTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + this.remote.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + this.remote.hasMoreMessages, + runtime.timeline.viewState(this.remote.hasMoreMessages) + ); + this.remote.setModelCatalog(state.modelCatalog, state.selectedModelId); + } + + startRemotePolling(): void { + this.requireRemoteRuntime().polling.startActiveSession({ + sessionId: this.remote.activeSession.sessionId || '', + cursor: this.currentChatPollingCursor(), + activeTurn: this.remote.activeTurnMessage + }); + } + + applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { + return; + } + runtime.timeline.applySnapshot(snapshot); + this.syncRemoteTimeline(); + this.knownPollVersionValue = snapshot.cursor.pollVersion; + this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; + this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; + if (snapshot.title.length > 0) { + this.remote.setActiveSession({ + sessionId: this.remote.activeSession.sessionId, + title: snapshot.title, + workspacePath: this.remote.activeSession.workspacePath, + agentType: this.remote.activeSession.agentType + }); + } + if (snapshot.modelCatalog) { + runtime.models.applyCatalog(snapshot.modelCatalog); + } + this.remote.setStatusText(this.hasRunningRemoteTurn() + ? RemoteI18n.t('status.desktopProcessing') + : RemoteI18n.t('status.messagesSynced')); + if (snapshot.shouldSyncAfterTurnEnded) { + this.syncAfterRemoteTurnEnded(); + } + } + + hasRunningRemoteTurn(): boolean { + return this.remote.activeTurnMessage.id.length > 0 && + (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + remoteActiveTurnId(): string { + const active = this.remote.activeTurnMessage; + if (active.turnId && active.turnId.length > 0) { + return active.turnId; + } + return active.id.indexOf('active-') === 0 ? active.id.slice('active-'.length) : ''; + } + + projectedRemoteTimelineItems(): ChatTimelineItem[] { + return this.requireRemoteRuntime().timeline.viewState(this.remote.hasMoreMessages); + } + + async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSession( + agentType, + '', + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + openRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.connection.ensureAvailable()) { + return; + } + const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; + this.remoteCreate.prepare(deviceId, deviceName, this.remote.selectedModelId); + if (deviceId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId, + deviceName: deviceName || deviceId, + online: true + }]); + } + this.remoteCreate.setWorkspaces(this.remote.recentWorkspaces); + runtime.appShell.pushRoute(AppRoute.RemoteCreate); + this.loadRemoteCreateChoices(); + this.loadRemoteCreateModelCatalog(); + } + + closeRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + this.remoteCreateWorkspaceLoadVersion += 1; + runtime.hooks.stopVoiceInput(); + this.remoteCreate.closeMenu(); + runtime.appShell.popRoute(AppRoute.RemoteHome); + } + + async loadRemoteCreateChoices(): Promise { + await Promise.all([ + this.loadRemoteCreateDevices(), + this.loadRemoteCreateWorkspaces() + ]); + } + + async loadRemoteCreateModelCatalog(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.modelCatalog.models.length > 0) { + return; + } + try { + const catalog = await runtime.sessionManager.getModelCatalog(); + const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); + this.remote.setModelCatalog(catalog, selectedModelId); + this.remoteCreate.setSelectedModelId(selectedModelId); + } catch (_err) { + // Model selection remains hidden when the remote does not expose a catalog. + } + } + + async loadRemoteCreateDevices(): Promise { + const runtime = this.requireRemoteRuntime(); + this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; + try { + const phoneDeviceId = runtime.connection.getDeviceId(); + const accountDevices = await runtime.settings.listCloudAccountDevices(); + const devices = accountDevices.filter((device: CloudAccountDevice): boolean => + device.online && device.deviceId !== phoneDeviceId + ); + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0 && + !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { + devices.unshift({ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }); + } + this.remoteCreate.setDevices(devices); + } catch (_err) { + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }]); + } else { + this.remoteCreate.setDevices([]); + } + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); + } + } + + async loadRemoteCreateWorkspaces(): Promise { + const runtime = this.requireRemoteRuntime(); + const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; + const deviceId = this.remoteCreate.selectedDeviceId; + this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; + try { + const workspaces = await runtime.workspace.recentWorkspaces(); + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces(workspaces); + } catch (_err) { + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces([]); + this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); + } + } + + toggleRemoteCreateDevices(): void { + this.remoteCreate.toggleMenu('devices'); + if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { + this.loadRemoteCreateDevices(); + } + } + + toggleRemoteCreateWorkspaces(): void { + this.remoteCreate.toggleMenu('workspaces'); + if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { + this.loadRemoteCreateWorkspaces(); + } + } + + async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { + const runtime = this.requireRemoteRuntime(); + if (device.deviceId === this.remoteCreate.selectedDeviceId) { + this.remoteCreate.closeMenu(); + return; + } + const draft = this.remoteCreate.draft; + this.remoteCreate.closeMenu(); + this.remoteCreate.isLoadingWorkspaces = true; + try { + await runtime.settings.selectCloudAccountDevice(device, false); + this.remoteCreate.selectDevice(device); + this.remoteCreate.setDraft(draft); + await this.loadRemoteCreateWorkspaces(); + } catch (err) { + this.remoteCreate.isLoadingWorkspaces = false; + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } + } + + selectRemoteCreateWorkspace(path: string): void { + const workspace = this.remoteCreate.workspaces + .find((item: RecentWorkspaceEntry): boolean => item.path === path); + this.remoteCreate.selectWorkspace(workspace); + } + + async submitRemoteCreateSession(): Promise { + const runtime = this.requireRemoteRuntime(); + const instruction = this.remoteCreate.draft.trim(); + if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { + return; + } + const context = this.remoteCreate.submissionContext(); + const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); + return; + } + this.remoteCreate.isSubmitting = true; + this.remoteCreate.errorText = ''; + this.remoteCreate.closeMenu(); + try { + if (context.workspacePath.length > 0) { + await runtime.sessions.createSessionInWorkspace( + context.workspacePath, + this.remote.workspacePath, + instruction, + context.agentType, + undefined, + this.remoteCreate.selectedModelId + ); + } else { + await this.bindAssistantWorkspace(); + await runtime.sessions.createSession( + context.agentType, + instruction, + undefined, + this.remoteCreate.selectedModelId + ); + } + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); + } + } catch (err) { + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.create.submitFailed'); + } finally { + this.remoteCreate.isSubmitting = false; + } + } + + /** + * The chat option creates a Claw session, and the desktop always binds those + * to its assistant workspace. Follow it there first, otherwise the app stays + * bound to the code workspace it was on and the new chat is listed, titled + * and file-scoped as if it had been created inside that workspace. + */ + private async bindAssistantWorkspace(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.workspaceKind === 'assistant') { + return; + } + try { + const assistants = await runtime.workspace.assistants(); + if (assistants.length === 0) { + return; + } + await runtime.hooks.selectAssistantWorkspace(assistants[0].path); + } catch (err) { + RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); + } + } + + async createRemoteSessionInWorkspace( + path: string, + agentType: string = 'code', + inPlace: boolean = false + ): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSessionInWorkspace( + path, + this.remote.workspacePath, + '', + agentType, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + async openRemoteSession(item: RemoteSession, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.openSession( + item, + this.remote.workspacePath, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + applyRemoteActiveSession(session: SessionSummary): void { + const runtime = this.requireRemoteRuntime(); + const current = this.remote.activeSession; + if (runtime.hooks.isFilePreviewVisible() && + (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { + runtime.filePreview.close(); + } + this.remote.setActiveSession(session); + } + + async deleteRemoteSession(item: RemoteSession): Promise { + await this.requireRemoteRuntime().sessions.deleteSession(item, this.remote.workspacePath); + } + + openHomeSession(session: RemoteSession, inPlace: boolean = false): void { + this.requireRemoteRuntime().filePreview.close(); + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.openRemoteSession(session, inPlace); + } + + async deleteHomeSession(session: RemoteSession): Promise { + if (session.agentType !== 'chat') { + await this.deleteRemoteSession(session); + return; + } + await this.requireRemoteRuntime().generalCommands.deleteSession(session, this.general.isBusy); + } + + activeGeneralChatAsRemoteSession(): RemoteSession { + const active = this.general.activeSession; + return { + id: active.sessionId, + title: active.title, + agentType: 'chat', + status: 'ready', + updatedAt: '', + createdAt: '', + messageCount: this.general.timelineItems.length, + workspacePath: active.workspacePath + }; + } + + activeGeneralUploadedFileCount(): number { + let count = 0; + this.general.timelineItems.forEach((item: ChatTimelineItem) => { + if (item.message && item.message.images) { + count += item.message.images.length; + } + }); + return count; + } + + async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { + await this.requireRemoteRuntime().generalCommands.archiveSession(session, archived, this.general.isBusy); + } + + async exportHomeSession(session: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.generalCommands.exportSession( + session, + this.general.isBusy, + async (text: string): Promise => runtime.clipboard.writeText(text) + ); + } + + async openGeneralSession(item: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + await runtime.generalCommands.openSession( + item, + this.general.isBusy, + async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + } + + async startGeneralChat(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + const trimmed = text.trim(); + if (trimmed.length === 0 || this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + runtime.generalDrafts.cancel(); + const created = await runtime.generalCommands.createSession( + trimmed, + this.general.isBusy, + async (): Promise => runtime.generalDrafts.clearHomeNow(), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + if (created) { + await runtime.generalConversation.sendMessage(); + } + } + + async sendVisibleMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + if ((this.general.activeSession.sessionId || '').length === 0) { + this.startVisibleGeneralChat(); + return; + } + await runtime.generalConversation.sendMessage(); + return; + } + await this.sendRemoteMessage(); + } + + async stopVisibleTask(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + runtime.generalConversation.stop(true); + return; + } + await this.stopRemoteTask(); + } + + closeActiveChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + runtime.hooks.stopVoiceInput(); + if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { + runtime.generalDrafts.persistVisible(this.general.chatInput); + runtime.generalConversation.stop(true); + runtime.appShell.popRoute(AppRoute.ChatHome); + this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); + return; + } + runtime.polling.stop(); + this.remote.setConversationDismissed(true); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + + async renameVisibleSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); + return; + } + await this.renameRemoteSession(title); + } + + async retryVisibleMessage(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + const prepared = await runtime.generalCommands.retryMessage( + this.general.activeSession.sessionId || '', text, this.general.isBusy + ); + if (prepared) { + await runtime.generalConversation.sendMessage(); + } + return; + } + this.retryRemoteMessage(text); + } + + downloadVisibleFile(path: string): void { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); + return; + } + this.downloadRemoteFile(path); + } + + async selectVisibleModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.settings.selectModel(modelId); + return; + } + await this.selectRemoteModel(modelId); + } + + startVisibleGeneralChat(): void { + const rawText = this.general.chatInput.trim(); + const text = rawText.length > 0 ? rawText : + (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + if (text.length === 0 || this.general.isBusy) { + return; + } + if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { + const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); + this.general.setStatus(statusText); + this.showHomeToast(statusText); + return; + } + this.startGeneralChat(text); + } + + generalChatHomeStatusText(): string { + if (this.general.serviceState === GeneralChatServiceState.Ready || + this.general.serviceState === GeneralChatServiceState.Sending || + this.general.serviceState === GeneralChatServiceState.Streaming) { + return ''; + } + return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); + } + + prepareNewGeneralChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.hooks.stopVoiceInput(); + runtime.generalConversation.stop(true); + runtime.generalDrafts.clearHome(); + this.general.clearComposer(); + this.general.clearActiveSession(); + this.resetGeneralTimeline(''); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); + } + + onVisibleChatInputChange(route: AppRoute, value: string): void { + this.setChatInput(route, value); + if (this.isGeneralComposerRoute(route)) { + this.requireRemoteRuntime().generalDrafts.scheduleVisible(value); + } + } + + visibleGeneralChatDraftId(): string { + return this.requireRemoteRuntime().appShell.isGeneralChatVisible() ? + this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; + } + + async restoreGeneralChatDraft(draftId: string): Promise { + this.general.setChatInput(await this.requireRemoteRuntime().generalDrafts.restore(draftId)); + } + + latestUserMessageText(): string { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + return this.general.latestUserMessageText(); + } + const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + resetGeneralTimeline(sessionId: string): void { + this.requireRemoteRuntime().timeline.reset(sessionId); + this.syncGeneralTimeline(); + } + + syncGeneralTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + const projectedItems = runtime.timeline.viewState(false); + this.general.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + false, + projectedItems + ); + const itemSummary = projectedItems.map((item: ChatTimelineItem) => { + const message = item.message; + return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; + }).join(','); + RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); + } + + showHomeToast(message: string): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.showToast(message)) { + this.setVisibleStatusText(message); + } + } + + private currentChatPollingCursor(): RemoteChatPollingCursor { + return { + pollVersion: this.knownPollVersionValue, + knownMessageCount: this.knownRemoteMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }; + } + + private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { + this.knownPollVersionValue = pollVersion; + this.knownRemoteMessageCount = knownMessageCount; + this.requireRemoteRuntime().polling.updateCursor({ + pollVersion, + knownMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }); + } + + private async syncAfterRemoteTurnEnded(): Promise { + if (this.isSyncingAfterTurn) { + return; + } + this.isSyncingAfterTurn = true; + try { + await this.loadRemoteMessages(); + } finally { + this.isSyncingAfterTurn = false; + } + } + + private shortSessionId(sessionId: string): string { + return sessionId.length <= 8 ? sessionId : + sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); + } + + routeCreatedRemoteSession(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); + return; + } + runtime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); + } + + private routeRemoteSessionInPlace(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + const target = AppRouteContract.remoteSessionDestination(sessionId); + runtime.appShell.replaceRouteWithoutAnimation(target.name, target.routeParam().sessionId); + } + + private requireRemoteRuntime(): RemoteConversationDependencies { + if (!this.remoteRuntime) { + throw new Error('Remote conversation dependencies are not configured.'); + } + return this.remoteRuntime; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets new file mode 100644 index 0000000000..fc8300b1f6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets @@ -0,0 +1,92 @@ +import { FilePreviewRequest, FilePreviewTargetContext } from '../../model/FilePreviewTarget'; +import { SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { RemoteFilePreviewController } from './RemoteFilePreviewController'; + +export interface FilePreviewControllerHooks { + readonly remoteAvailable: () => boolean; + readonly activeSession: () => SessionSummary; + readonly workspacePath: () => string; + readonly openExternalLink: (reference: string) => Promise; + readonly onGeneralStatus: (statusText: string) => void; + readonly onRemoteStatus: (statusText: string) => void; +} + +/** Owns file-preview routing, target validity and the underlying remote file load. */ +export class FilePreviewController { + private readonly state: FilePreviewState; + private readonly hooks: FilePreviewControllerHooks; + private readonly loader: RemoteFilePreviewController; + private controlTargetEpoch: number = 1; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + hooks: FilePreviewControllerHooks + ) { + this.state = state; + this.hooks = hooks; + this.loader = new RemoteFilePreviewController( + client, + state, + hooks.remoteAvailable, + (): number => this.controlTargetEpoch + ); + } + + open(route: AppRoute, request: FilePreviewRequest): void { + const activeSession = this.hooks.activeSession(); + const context = new FilePreviewTargetContext( + activeSession.sessionId, + activeSession.workspacePath || this.hooks.workspacePath(), + this.controlTargetEpoch + ); + const resolution = FileTargetResolver.resolve(request.reference, request.label, context); + if (resolution.kind === FileReferenceKind.HttpUrl) { + void this.openExternalLink(route, request.reference); + return; + } + if (route !== AppRoute.RemoteChat) { + this.hooks.onGeneralStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); + return; + } + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { + return; + } + void this.loader.open(resolution.target); + } + + close(): void { + this.loader.close(); + } + + refresh(): void { + void this.loader.refresh(); + } + + openLink(reference: string, label: string): void { + this.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + } + + invalidate(): void { + this.controlTargetEpoch += 1; + this.loader.close(); + } + + private async openExternalLink(route: AppRoute, reference: string): Promise { + const opened = await this.hooks.openExternalLink(reference); + if (opened) { + return; + } + const statusText = RemoteI18n.t('errors.operationFailed'); + if (AppRouteContract.isGeneralComposerRoute(route)) { + this.hooks.onGeneralStatus(statusText); + return; + } + this.hooks.onRemoteStatus(statusText); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets index cd489b440c..0248180436 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets @@ -8,7 +8,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { ConversationViewModel } from './ConversationViewModel'; -import { GeneralChatPageState } from './GeneralChatPageState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; @@ -22,26 +22,12 @@ import { } from '../../services/general-chat/GeneralChatPort'; import { RemoteLogger } from '../../services/RemoteLogger'; -export class GeneralChatConversationViewModelHooks { +export interface GeneralChatConversationViewModelHooks { readonly isVisible: (sessionId: string) => boolean; readonly currentActiveTurnId: () => string; readonly latestUserMessageText: () => string; readonly syncTimeline: () => void; readonly refreshSessions: () => void; - - constructor( - isVisible: (sessionId: string) => boolean, - currentActiveTurnId: () => string, - latestUserMessageText: () => string, - syncTimeline: () => void, - refreshSessions: () => void - ) { - this.isVisible = isVisible; - this.currentActiveTurnId = currentActiveTurnId; - this.latestUserMessageText = latestUserMessageText; - this.syncTimeline = syncTimeline; - this.refreshSessions = refreshSessions; - } } /** Owns General Chat stream state and publishes all updates through ConversationViewModel. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index fd029d22ec..8228df45dd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -5,7 +5,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -export class RemoteActivityViewModelHooks { +export interface RemoteActivityViewModelHooks { readonly isConnected: () => boolean; readonly isBusy: () => boolean; readonly hasRemoteBinding: () => boolean; @@ -20,38 +20,6 @@ export class RemoteActivityViewModelHooks { readonly onPoll: () => Promise; readonly onReconnect: () => Promise; readonly onRestoreSession: (session: SessionSummary) => Promise; - - constructor( - isConnected: () => boolean, - isBusy: () => boolean, - hasRemoteBinding: () => boolean, - isRemoteChat: () => boolean, - activeSession: () => SessionSummary, - onConnectionState: (state: string) => void, - onStatus: (status: string) => void, - onConnectionError: (err: Object) => Promise, - onStopHeartbeat: () => void, - onStartPolling: () => void, - onStopPolling: () => void, - onPoll: () => Promise, - onReconnect: () => Promise, - onRestoreSession: (session: SessionSummary) => Promise - ) { - this.isConnected = isConnected; - this.isBusy = isBusy; - this.hasRemoteBinding = hasRemoteBinding; - this.isRemoteChat = isRemoteChat; - this.activeSession = activeSession; - this.onConnectionState = onConnectionState; - this.onStatus = onStatus; - this.onConnectionError = onConnectionError; - this.onStopHeartbeat = onStopHeartbeat; - this.onStartPolling = onStartPolling; - this.onStopPolling = onStopPolling; - this.onPoll = onPoll; - this.onReconnect = onReconnect; - this.onRestoreSession = onRestoreSession; - } } /** Owns foreground recovery, heartbeat health checks, and idempotent resume cancellation. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets similarity index 99% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index a15d5ca378..40e66955c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -16,7 +16,7 @@ import { RemoteSessionController } from '../../services/RemoteSessionController' import { RemoteUiState } from '../../services/RemoteUiState'; import { QrScanService } from '../../services/QrScanService'; import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemoteLogger } from '../../services/RemoteLogger'; @@ -30,7 +30,7 @@ export enum RemoteConnectionState { Disconnected = 'disconnected' } -export class RemoteConnectionViewModel { +export class RemoteConnectionController { private readonly pageState: RemotePageState; private readonly identity: MobileIdentityStore; private readonly pairing: RemotePairingPolicy; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets index a3299361f5..c2de266168 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets @@ -1,15 +1,15 @@ -import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../../model/RemoteModels'; import { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState -} from '../pages/state/FilePreviewState'; -import { FilePreviewTarget } from '../pages/state/FilePreviewTarget'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { Encoding } from './Encoding'; -import { FilePreviewErrorPolicy } from './FilePreviewErrorPolicy'; -import { FilePreviewPolicy } from './FilePreviewPolicy'; -import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; +} from '../state/FilePreviewState'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewErrorPolicy } from '../../services/FilePreviewErrorPolicy'; +import { FilePreviewPolicy } from '../../services/FilePreviewPolicy'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; export class RemoteFilePreviewController { private readonly client: RemoteWorkspaceFileClient; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets similarity index 74% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index fbedef6d7d..42ab00f630 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -4,9 +4,9 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteModelController } from '../../services/RemoteModelController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteSessionViewModelHooks { +export interface RemoteSessionViewModelHooks { readonly remoteAvailable: () => boolean; readonly isConnected: () => boolean; readonly isBusy: () => boolean; @@ -22,40 +22,6 @@ export class RemoteSessionViewModelHooks { readonly onLoadActiveMessages: () => Promise; readonly onRefreshSessions: () => Promise; readonly onSelectWorkspace: (path: string) => Promise; - - constructor( - remoteAvailable: () => boolean, - isConnected: () => boolean, - isBusy: () => boolean, - onBusy: (busy: boolean) => void, - onRouteChat: (sessionId: string) => void, - onRouteHome: () => void, - onStopPolling: () => void, - onStartPolling: () => void, - onResetTimeline: (sessionId: string) => void, - onClearRemoteFiles: () => void, - onKnownStateReset: () => void, - onLoadModelCatalog: (sessionId: string) => Promise, - onLoadActiveMessages: () => Promise, - onRefreshSessions: () => Promise, - onSelectWorkspace: (path: string) => Promise - ) { - this.remoteAvailable = remoteAvailable; - this.isConnected = isConnected; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onRouteChat = onRouteChat; - this.onRouteHome = onRouteHome; - this.onStopPolling = onStopPolling; - this.onStartPolling = onStartPolling; - this.onResetTimeline = onResetTimeline; - this.onClearRemoteFiles = onClearRemoteFiles; - this.onKnownStateReset = onKnownStateReset; - this.onLoadModelCatalog = onLoadModelCatalog; - this.onLoadActiveMessages = onLoadActiveMessages; - this.onRefreshSessions = onRefreshSessions; - this.onSelectWorkspace = onSelectWorkspace; - } } /** Owns remote session commands and their page lifecycle effects. */ @@ -158,26 +124,38 @@ export class RemoteSessionViewModel { currentWorkspacePath: string, onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { - await this.sessions.open( - item, - item.workspacePath || currentWorkspacePath, - this.hooks.isBusy(), - this.hooks.remoteAvailable(), - async (session: SessionSummary): Promise => { - this.hooks.onStopPolling(); - this.hooks.onResetTimeline(item.id); - this.hooks.onKnownStateReset(); - this.pageState.setHasMoreMessages(false); - this.files.clear(); - this.pageState.clearComposer(); - onRouteChat(item.id); - await this.hooks.onLoadModelCatalog(item.id); - await this.hooks.onLoadActiveMessages(); - if (this.pageState.activeSession.sessionId === session.sessionId) { - this.hooks.onStartPolling(); + const isBusy = this.hooks.isBusy(); + const remoteAvailable = this.hooks.remoteAvailable(); + if (isBusy || item.id.length === 0 || !remoteAvailable) { + return; + } + this.pageState.setPendingSessionId(item.id); + this.pageState.setConversationLoading(true); + onRouteChat(item.id); + try { + await this.sessions.open( + item, + item.workspacePath || currentWorkspacePath, + false, + true, + async (session: SessionSummary): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(item.id); + this.hooks.onKnownStateReset(); + this.pageState.setHasMoreMessages(false); + this.files.clear(); + this.pageState.clearComposer(); + await this.hooks.onLoadModelCatalog(item.id); + await this.hooks.onLoadActiveMessages(); + if (this.pageState.activeSession.sessionId === session.sessionId) { + this.hooks.onStartPolling(); + } } - } - ); + ); + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } } async deleteSession(item: RemoteSession, currentWorkspacePath: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets similarity index 86% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 87b9818dd8..f6d3d475ac 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -2,9 +2,9 @@ import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteWorkspaceViewModelHooks { +export interface RemoteWorkspaceViewModelHooks { readonly isRemoteAvailable: () => boolean; readonly isBusy: () => boolean; readonly onBusy: (isBusy: boolean) => void; @@ -13,26 +13,6 @@ export class RemoteWorkspaceViewModelHooks { readonly onSessionsDiscovered: (sessions: RemoteSession[]) => void; readonly onRefreshSessions: () => Promise; readonly onConnectionFailure: (error: Object) => void; - - constructor( - isRemoteAvailable: () => boolean, - isBusy: () => boolean, - onBusy: (isBusy: boolean) => void, - onStatus: (statusText: string) => void, - onWorkspaceSelected: (workspace: WorkspaceInfo) => void, - onSessionsDiscovered: (sessions: RemoteSession[]) => void, - onRefreshSessions: () => Promise, - onConnectionFailure: (error: Object) => void - ) { - this.isRemoteAvailable = isRemoteAvailable; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onStatus = onStatus; - this.onWorkspaceSelected = onWorkspaceSelected; - this.onSessionsDiscovered = onSessionsDiscovered; - this.onRefreshSessions = onRefreshSessions; - this.onConnectionFailure = onConnectionFailure; - } } /** Owns the workspace/assistant picker workflows and their presentation state. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets new file mode 100644 index 0000000000..02e90e9f0b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice, CloudAccountRequestError, CloudAccountSession, CloudAccountClient } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { Encoding } from '../../services/Encoding'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigUpdate, + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; +import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemotePermissionMode } from '../../model/RemoteModels'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; + +export interface SettingsControllerHooks { + readonly probeConfiguration: (apiUrl: string, apiKey: string, modelName: string) => Promise; +} + +export interface CloudAccountSettingsHooks { + readonly deviceId: () => string; + readonly remoteAvailable: () => boolean; + readonly invalidatePreview: () => void; + readonly invalidateRemoteActivity: () => void; + readonly invalidateRemoteConnection: () => void; + readonly stopPolling: () => void; + readonly stopHeartbeat: () => void; + readonly startHeartbeat: () => void; + readonly resetTimeline: () => void; + readonly resetKnownRemoteState: () => void; + readonly closeSettings: () => void; + readonly closeConnectSheet: () => void; + readonly navigateRemoteHome: () => void; + readonly loadRecentWorkspaces: () => Promise; +} + +export interface CloudAccountSettingsDependencies { + readonly client: CloudAccountClient; + readonly sessionStore: CloudAccountSessionStore; + readonly sessionManager: RemoteSessionManager; + readonly remoteState: RemotePageState; + readonly hooks: CloudAccountSettingsHooks; +} + +/** Owns general-chat model service settings and their presentation projection. */ +export class SettingsController { + private readonly store: GeneralChatConfigStore; + private readonly state: GeneralChatPageState; + private readonly hooks: SettingsControllerHooks; + private readonly cloud?: CloudAccountSettingsDependencies; + private cloudSession?: CloudAccountSession; + private cloudRelayUrl: string = ''; + + constructor( + store: GeneralChatConfigStore, + state: GeneralChatPageState, + hooks: SettingsControllerHooks, + cloud?: CloudAccountSettingsDependencies + ) { + this.store = store; + this.state = state; + this.hooks = hooks; + this.cloud = cloud; + } + + async save( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (!update.clearApiKey) { + const probeError = await this.probe(update); + if (probeError.length > 0) { + return probeError; + } + } + const catalogBeforeSave = await this.store.modelCatalog(); + const snapshot = await this.store.save(update); + if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { + await this.store.selectLocalModel(); + } + this.apply(snapshot); + await this.refreshModelCatalog(); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + async test( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (update.clearApiKey) { + return RemoteI18n.t('settings.modelService.testNeedsKey'); + } + return await this.probe(update); + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + apply(snapshot: GeneralChatConfigSnapshot): void { + this.state.setConfiguration( + snapshot.apiUrl, + snapshot.modelName, + snapshot.hasApiKey, + GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) + ); + } + + async refreshModelCatalog(): Promise { + const catalog = await this.store.modelCatalog(); + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + this.state.setModelCatalog(catalog, selectedModelId); + const active = await this.store.activeSnapshot(); + this.state.setServiceState( + GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) + ); + } + + async selectModel(modelId: string): Promise { + if (!await this.store.selectModel(modelId)) { + return false; + } + await this.refreshModelCatalog(); + return true; + } + + async initializeCloudAccount(context: Context): Promise { + const cloud = this.requireCloud(); + await cloud.sessionStore.init(context); + await this.restoreCloudAccountSession(); + } + + hasCloudAccountSession(): boolean { + return this.cloudSession !== undefined; + } + + async persistDelegatedAccountSession(): Promise { + if (this.cloudSession) { + return; + } + const cloud = this.requireCloud(); + const delegated = cloud.sessionManager.delegatedAccountSession(); + if (!delegated) { + return; + } + this.applyCloudAccountSession(delegated.session, delegated.relayUrl, delegated.session.userId); + await cloud.sessionStore.save({ + relayUrl: delegated.relayUrl, + username: delegated.session.userId, + token: delegated.session.token, + userId: delegated.session.userId, + masterKey: Encoding.bytesToBase64(delegated.session.masterKey) + }); + RemoteLogger.info('delegated account session persisted after room pairing'); + } + + async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + const cloud = this.requireCloud(); + RemoteLogger.info('cloud account UI login requested'); + const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + this.applyCloudAccountSession(session, relayUrl, username); + await cloud.sessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + await this.loadGeneralChatAccountModels(session, relayUrl); + RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info(`cloud account login success user=${session.userId}`); + return session.userId; + } + + async syncCloudAccount(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + let bundles: Object[]; + try { + bundles = await cloud.client.fetchSessions(this.cloudRelayUrl, session, 0); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); + } + await this.loadGeneralChatAccountModels(session, this.cloudRelayUrl); + RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); + return String(bundles.length); + } + + applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { + const remoteState = this.requireCloud().remoteState; + this.cloudSession = session; + this.cloudRelayUrl = relayUrl.trim(); + remoteState.setAccountUserId(session.userId); + remoteState.setAccountUsername(username.trim()); + } + + async logoutCloudAccount(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(true); + } + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + cloud.remoteState.clearControlTarget(); + RemoteLogger.info('cloud account logout success'); + } + + async listCloudAccountDevices(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return []; + } + try { + return await cloud.client.listDevices(this.cloudRelayUrl, session); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); + } + } + + async getRemotePermissionMode(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.getPermissionMode(); + } + + async setRemotePermissionMode(mode: RemotePermissionMode): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.setPermissionMode(mode); + } + + async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + const targetId = targetDeviceId.trim(); + if (targetId.length === 0) { + return; + } + const remoteState = this.requireCloud().remoteState; + try { + const devices = await this.listCloudAccountDevices(); + const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); + if (!target || !target.online) { + const targetName = target?.deviceName || targetDeviceName || targetId; + remoteState.setControlTarget('account_device', targetId, targetName); + remoteState.setDesktopIdentity(targetName, targetId); + remoteState.setConnectionState('failed'); + remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + return; + } + await this.selectCloudAccountDevice({ + deviceId: target.deviceId, + deviceName: target.deviceName || targetDeviceName || target.deviceId, + online: target.online, + lastSeenAt: target.lastSeenAt + }); + } catch (err) { + RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + async handleRemoteConnectionError(err: Object): Promise { + const remoteState = this.requireCloud().remoteState; + if (remoteState.controlTargetType !== 'account_device' || + !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { + return false; + } + await this.expireCloudAccountSession(); + remoteState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); + return true; + } + + async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!device.online) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + const deviceId = device.deviceId.trim(); + if (deviceId.length === 0 || deviceId === cloud.hooks.deviceId()) { + return; + } + if (deviceId === cloud.remoteState.controlTargetDeviceId && cloud.remoteState.connectionState === 'connected') { + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + return; + } + this.prepareAccountDeviceConnection(); + try { + const initialSync = await cloud.sessionManager.connectAccountDevice( + cloud.client, + this.cloudRelayUrl, + session, + deviceId + ); + cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); + cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); + cloud.remoteState.setWorkspace( + initialSync.workspace.name, + initialSync.workspace.path, + initialSync.workspace.assistantId || '', + initialSync.workspace.gitBranch, + initialSync.workspace.workspaceKind || 'normal' + ); + cloud.remoteState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); + cloud.remoteState.setConnectionState('connected'); + cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); + cloud.hooks.closeSettings(); + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + await cloud.sessionStore.save({ + relayUrl: this.cloudRelayUrl, + username: cloud.remoteState.accountUsername, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey), + targetDeviceId: deviceId, + targetDeviceName: device.deviceName + }); + cloud.hooks.startHeartbeat(); + await cloud.hooks.loadRecentWorkspaces(); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('failed'); + const message = ConnectionErrorPolicy.errorText(err); + cloud.remoteState.setStatusText(message); + cloud.sessionManager.reset(); + throw new Error(message); + } finally { + cloud.remoteState.setLoadingHome(false); + cloud.remoteState.setBusy(false); + } + } + + private update( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): GeneralChatConfigUpdate { + return { apiUrl, apiKey, modelName, clearApiKey }; + } + + private async validate(update: GeneralChatConfigUpdate): Promise { + const snapshot = await this.store.snapshot(); + return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); + } + + private async probe(update: GeneralChatConfigUpdate): Promise { + const apiKey = await this.effectiveApiKey(update); + if (apiKey.length === 0) { + return RemoteI18n.t('settings.modelService.apiKeyRequired'); + } + try { + await this.hooks.probeConfiguration(update.apiUrl, apiKey, update.modelName); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + private async effectiveApiKey(update: GeneralChatConfigUpdate): Promise { + const directKey = update.apiKey.trim(); + if (directKey.length > 0) { + return directKey; + } + if (update.clearApiKey) { + return ''; + } + return (await this.store.accessToken()).trim(); + } + + private async restoreCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + try { + const persisted = await cloud.sessionStore.load(); + if (!persisted) { + return; + } + const session: CloudAccountSession = { + token: persisted.token, + userId: persisted.userId, + masterKey: Encoding.base64ToBytes(persisted.masterKey) + }; + this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + await this.loadGeneralChatAccountModels(session, persisted.relayUrl); + } catch (err) { + RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + await cloud.sessionStore.clear(); + } + } + + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { + const cloud = this.requireCloud(); + this.store.replaceAccountModels([]); + try { + const blob = await cloud.client.fetchSettings(relayUrl, session); + if (!blob) { + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + RemoteLogger.info('cloud model catalog is empty'); + return; + } + const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); + this.store.replaceAccountModels(models); + await this.refreshModelCatalog(); + RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); + } catch (err) { + await this.refreshModelCatalog(); + RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + private async expireCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(false); + } + } + + private prepareAccountDeviceConnection(): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.invalidateRemoteConnection(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.remoteState.setConnectionState('reconnecting'); + cloud.remoteState.setLoadingHome(true); + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setBusy(true); + cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + cloud.remoteState.clearActiveSession(); + cloud.hooks.resetTimeline(); + cloud.hooks.resetKnownRemoteState(); + cloud.remoteState.setSessions([], false); + } + + private resetAccountDeviceConnection(clearWorkspace: boolean): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.sessionManager.reset(); + cloud.remoteState.clearActiveSession(); + cloud.remoteState.setSessions([], false); + if (clearWorkspace) { + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setAuthenticatedUserId(''); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('disconnected'); + } + + private requireCloud(): CloudAccountSettingsDependencies { + if (!this.cloud) { + throw new Error('Cloud account settings dependencies are not configured.'); + } + return this.cloud; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets index a83708868e..1d438f4309 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { RemoteUiState } from './RemoteUiState'; export enum FileReferenceKind { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets index a891801a75..528e3bf957 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -1,4 +1,4 @@ -import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; import { MarkdownParser, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 124f69ff32..22d8c6438e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#F8FAFF" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#F4F3F0" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 39e3e9d2c5..9252b40cea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#252522" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#2D2C28" diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 1af9cec0d6..8db8af9f1a 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,8 +1,9 @@ import { describe, expect, it } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; +import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/model/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { externalLinks: string[] = []; @@ -30,41 +31,36 @@ class FakeAppRootHost implements AppRootHostPort { } class TestAppRootRuntime extends AppRootRuntime { - stopGeneralChatStreamCalls: number = 0; - constructor(host: AppRootHostPort = new FakeAppRootHost()) { super(host); } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.stopGeneralChatStreamCalls += 1; - super.stopGeneralChatStream(cancelled, finalStatus); - } } export default function appRootLifecycleUnitTest() { describe('AppRootRuntime page hide lifecycle', () => { it('keeps backgrounded general chat running on page hide', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.onPageHide(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(0); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertTrue(); }); it('still performs general chat cleanup when the app truly disappears', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.aboutToDisappear(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertFalse(); }); it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { const host = new FakeAppRootHost(); const runtime = new TestAppRootRuntime(host); - runtime.openFilePreview( + runtime.filePreviewController.open( AppRoute.ChatHome, new FilePreviewRequest('https://example.com/docs', 'docs') ); @@ -75,6 +71,22 @@ export default function appRootLifecycleUnitTest() { expect(runtime.filePreviewState.visible).assertFalse(); }); + it('reports external-link failures through the active conversation surface', 0, async () => { + const host = new FakeAppRootHost(); + host.externalLinkResult = false; + const runtime = new TestAppRootRuntime(host); + + runtime.filePreviewController.open( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/failure', 'failure') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(runtime.generalChatPageState.conversation.statusText) + .assertEqual(RemoteI18n.t('errors.operationFailed')); + expect(runtime.remotePageState.conversation.statusText).assertEqual(''); + }); + it('closes preview before applying conversation navigation back', 0, () => { const runtime = new TestAppRootRuntime(); runtime.filePreviewState.begin(new FilePreviewTarget( @@ -92,7 +104,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.invalidateFilePreviewTarget(); + runtime.filePreviewController.invalidate(); expect(runtime.filePreviewState.visible).assertFalse(); }); @@ -109,7 +121,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-1', title: 'Renamed session', workspacePath: '/workspace', @@ -117,7 +129,7 @@ export default function appRootLifecycleUnitTest() { }); expect(runtime.filePreviewState.visible).assertTrue(); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-2', title: 'Session 2', workspacePath: '/workspace', diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets index 060199acea..0d4d2e8560 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets @@ -1,8 +1,7 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { CloudAccountSession } from '../main/ets/services/CloudAccountClient'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; class FakeAppRootHost implements AppRootHostPort { attach(_context: Context, _uiContext: UIContext): void { @@ -21,21 +20,11 @@ class FakeAppRootHost implements AppRootHostPort { } } -class TestAppRootRuntime extends AppRootRuntime { - constructor() { - super(new FakeAppRootHost()); - } - - applySession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.applyCloudAccountSession(session, relayUrl, username); - } -} - export default function appRootRuntimeStartupUnitTest() { describe('AppRootRuntime startup restore', () => { it('applies cloud credentials without selecting a remote target', 0, () => { - const runtime = new TestAppRootRuntime(); - runtime.applySession({ + const runtime = new AppRootRuntime(new FakeAppRootHost()); + runtime.settingsController.applyCloudAccountSession({ token: 'token-1', userId: 'user-1', masterKey: new Uint8Array(32) @@ -45,7 +34,7 @@ export default function appRootRuntimeStartupUnitTest() { expect(runtime.remotePageState.accountUsername).assertEqual('alice'); expect(runtime.remotePageState.controlTargetType).assertEqual('none'); expect(runtime.remotePageState.controlTargetDeviceId).assertEqual(''); - expect(runtime.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.ChatHome); }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 1139637387..4161ebe9fd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -7,8 +7,9 @@ import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ChatMessage } from '../main/ets/model/RemoteModels'; -import { AppShellViewModel } from '../main/ets/pages/state/AppShellViewModel'; +import { AppShellViewModel } from '../main/ets/pages/viewmodel/AppShellViewModel'; import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; +import { WideLayoutGeometry } from '../main/ets/pages/layout/WideLayoutGeometry'; export default function architectureUnitTest() { describe('MobileArchitecture', () => { @@ -91,5 +92,15 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps wide layout geometry pure and deterministic', 0, () => { + expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); + expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); + expect(WideLayoutGeometry.detailWidth(true, 900, 1200)).assertEqual(1200); + expect(WideLayoutGeometry.collapsedVisualBias(true, 0, 1100, 920, 72)).assertEqual(72); + expect(WideLayoutGeometry.collapsedVisualBias(false, 0, 1100, 920, 72)).assertEqual(0); + expect(WideLayoutGeometry.areaLength('1080')).assertEqual(1080); + expect(WideLayoutGeometry.areaLength('invalid')).assertEqual(0); + }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 1c6a270efd..57de0bb8df 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -80,9 +80,12 @@ import { } from '../main/ets/services/VoiceInputLifecycleController'; import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { ConversationCoreState } from '../main/ets/pages/state/ConversationCoreState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -564,6 +567,81 @@ export default function conversationStateUnitTest() { }); }); + describe('ConversationController', () => { + it('keeps composer state isolated while the visible route changes', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + let route = AppRoute.ChatHome; + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => route } + ); + + controller.setChatInput(AppRoute.ChatHome, 'general draft'); + controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); + controller.setChatInput(AppRoute.RemoteCreate, 'create draft'); + + expect(controller.visibleChatInput()).assertEqual('general draft'); + route = AppRoute.RemoteChat; + expect(controller.visibleChatInput()).assertEqual('remote draft'); + route = AppRoute.RemoteCreate; + expect(controller.visibleChatInput()).assertEqual('create draft'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + + it('clears voice state for every conversation surface on teardown', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => AppRoute.RemoteCreate } + ); + controller.setVoiceListening(AppRoute.ChatHome, true); + controller.setVoiceListening(AppRoute.RemoteChat, true); + controller.setVoiceListening(AppRoute.RemoteCreate, true); + + controller.clearAllVoiceListening(); + + expect(general.isVoiceListening).assertFalse(); + expect(remote.isVoiceListening).assertFalse(); + expect(remoteCreate.isVoiceListening).assertFalse(); + }); + }); + + describe('ConversationCoreState', () => { + it('owns shared conversation data while keeping product surfaces isolated', 0, () => { + const general = new ConversationCoreState('chat'); + const remote = new ConversationCoreState('code'); + general.setActiveSession({ + sessionId: 'general-core', title: 'General', workspacePath: '', agentType: 'code' + }); + remote.setActiveSession({ + sessionId: 'remote-core', title: 'Remote', workspacePath: '/workspace', agentType: 'code' + }); + general.setChatInput('general draft'); + remote.setChatInput('remote draft'); + general.setBusy(true); + + expect(general.activeSession.agentType).assertEqual('chat'); + expect(remote.activeSession.agentType).assertEqual('code'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + expect(remote.isBusy).assertFalse(); + + general.clearActiveSession(); + expect(general.activeSession.sessionId).assertEqual(''); + expect(remote.activeSession.sessionId).assertEqual('remote-core'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + }); + describe('GeneralChatPageState', () => { it('projects configuration, busy state, and status text', 0, () => { const state = new GeneralChatPageState(); @@ -797,6 +875,9 @@ export default function conversationStateUnitTest() { }); state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); state.setModelCatalog(modelCatalog, 'model-a'); + state.setConversationLoading(true); + state.setPendingSessionId('remote-2'); + state.setConversationDismissed(true); timelineItems.length = 0; expect(state.activeSession.sessionId).assertEqual('remote-1'); @@ -805,6 +886,14 @@ export default function conversationStateUnitTest() { expect(state.hasRunningActiveTurn()).assertTrue(); expect(state.modelCatalog.version).assertEqual(2); expect(state.selectedModelId).assertEqual('model-a'); + expect(state.isLoadingConversation).assertTrue(); + expect(state.pendingSessionId).assertEqual('remote-2'); + expect(state.isConversationDismissed).assertTrue(); + + state.clearActiveSession(); + expect(state.pendingSessionId).assertEqual(''); + expect(state.isLoadingConversation).assertFalse(); + expect(state.isConversationDismissed).assertFalse(); }); it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { @@ -932,6 +1021,7 @@ export default function conversationStateUnitTest() { remote.setActiveSession({ sessionId: 'remote-session', title: 'Remote', workspacePath: '/repo', agentType: 'code' }); + remote.setConversationLoading(true); const general = new GeneralChatPageState(); general.setChatInput('general draft'); general.setActiveSession({ @@ -942,6 +1032,7 @@ export default function conversationStateUnitTest() { expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); expect(remoteProjection.chatInput).assertEqual('remote draft'); expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + expect(remoteProjection.isLoadingConversation).assertTrue(); const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); expect(generalProjection.surface).assertEqual(ChatSurface.General); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 3fbd4c6b56..97a364d5b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -189,6 +189,18 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertFalse(); expect(state.showConnectSheet).assertFalse(); }); + + it('mirrors the resolved layout mode for runtime branching', 0, () => { + const state = new AppShellState(); + + expect(state.wideLayout).assertFalse(); + state.setWideLayout(true); + expect(state.wideLayout).assertTrue(); + + state.setSidebarVisible(true); + state.closeGlobalSurfaces(); + expect(state.wideLayout).assertTrue(); + }); }); describe('AsyncLifecycleGate', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index eaf64f2b06..471cc8ef7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -76,7 +76,7 @@ import { MessageFileReferenceProjectionCache, MessageFileReferenceProjector } from '../main/ets/services/MessageFileReferenceProjector'; -import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; +import { RemoteFilePreviewController } from '../main/ets/pages/viewmodel/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -107,18 +107,18 @@ import { FilePreviewRendererKind, FilePreviewState } from '../main/ets/pages/state/FilePreviewState'; -import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/model/FilePreviewTarget'; import { FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../main/ets/pages/state/FilePreviewPlacementPolicy'; +} from '../main/ets/pages/policy/FilePreviewPlacementPolicy'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../main/ets/pages/state/ConversationLayoutPolicy'; -import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/state/SessionActionPolicy'; -import { ConversationSessionFilterPolicy } from '../main/ets/pages/state/ConversationSessionFilterPolicy'; -import { ConversationModelPresentationPolicy } from '../main/ets/pages/state/ConversationModelPresentationPolicy'; +} from '../main/ets/pages/policy/ConversationLayoutPolicy'; +import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/policy/SessionActionPolicy'; +import { ConversationSessionFilterPolicy } from '../main/ets/pages/policy/ConversationSessionFilterPolicy'; +import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -788,7 +788,7 @@ export default function remoteControllersUnitTest() { expect(state.selectedWorkspaceName).assertEqual('BitFun'); }); - it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + it('freezes the selected creation target and pairs a workspace with the code agent', 0, () => { const state = new RemoteCreateSessionState(); state.prepare('desktop-b', 'Desktop B'); state.setWorkspaces([{ @@ -802,6 +802,17 @@ export default function remoteControllersUnitTest() { expect(context.deviceId).assertEqual('desktop-b'); expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('code'); + }); + + it('keeps the chat option on the assistant agent so the desktop binds its assistant workspace', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.selectWorkspace(undefined); + + const context = state.submissionContext(); + + expect(context.workspacePath).assertEqual(''); expect(context.agentType).assertEqual('Claw'); }); }); @@ -1677,6 +1688,14 @@ export default function remoteControllersUnitTest() { expect(remoteChat.name).assertEqual(AppRoute.RemoteChat); expect(remoteChat.routeParam().sessionId).assertEqual('remote-1'); }); + + it('routes an in-place remote session selection to an explicit chat destination', 0, () => { + const target = AppRouteContract.remoteSessionDestination('remote-session-1'); + + expect(target.name).assertEqual(AppRoute.RemoteChat); + expect(target.hasSessionParam()).assertTrue(); + expect(target.routeParam().sessionId).assertEqual('remote-session-1'); + }); }); describe('SessionActionPolicy', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index e6b32f7f91..d083ef6550 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,6 +20,7 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, + GeneralChatConfigUpdate, GeneralChatConfigValidator, GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; @@ -84,6 +85,7 @@ import { import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { SettingsController } from '../main/ets/pages/viewmodel/SettingsController'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { @@ -217,6 +219,54 @@ function modelProviderRecordedResponse(statusCode: number, body: string): ModelP return response; } +class InMemorySettingsConfigStore extends GeneralChatConfigStore { + snapshotResult: GeneralChatConfigSnapshot = { + apiUrl: '', + modelName: '', + hasApiKey: false + }; + accessTokenResult: string = ''; + modelCatalogResults: RemoteModelCatalog[] = []; + modelCatalogCalls: number = 0; + saveRequests: GeneralChatConfigUpdate[] = []; + selectLocalModelCalls: number = 0; + + async snapshot(): Promise { + return this.snapshotResult; + } + + async save(update: GeneralChatConfigUpdate): Promise { + this.saveRequests.push(update); + this.snapshotResult = { + apiUrl: update.apiUrl.trim(), + modelName: update.modelName.trim(), + hasApiKey: !update.clearApiKey + }; + return this.snapshotResult; + } + + async accessToken(): Promise { + return this.accessTokenResult; + } + + async modelCatalog(): Promise { + const index = Math.min(this.modelCatalogCalls, this.modelCatalogResults.length - 1); + this.modelCatalogCalls += 1; + if (index >= 0) { + return this.modelCatalogResults[index]; + } + return { version: 1, models: [], default_models: {} }; + } + + async selectLocalModel(): Promise { + this.selectLocalModelCalls += 1; + } + + async activeSnapshot(): Promise { + return this.snapshotResult; + } +} + export default function transportAndGeneralChatUnitTest() { describe('RemoteDescriptorParser', () => { it('parses hash route URLs', 0, () => { @@ -748,6 +798,66 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('SettingsController', () => { + it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.snapshotResult = { + apiUrl: 'https://chat.example.com', + modelName: 'model-a', + hasApiKey: true + }; + store.accessTokenResult = ' stored-key '; + let probedApiKey = ''; + const controller = new SettingsController(store, new GeneralChatPageState(), { + probeConfiguration: async (_apiUrl: string, apiKey: string, _modelName: string): Promise => { + probedApiKey = apiKey; + } + }); + + const error = await controller.test('https://chat.example.com', '', 'model-a', false); + + expect(error).assertEqual(''); + expect(probedApiKey).assertEqual('stored-key'); + }); + + it('saves the first local model and projects its catalog into page state', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.modelCatalogResults = [ + { version: 1, models: [], default_models: {} }, + { + version: 2, + models: [{ + id: 'local-general-chat', + name: 'model-a', + provider: 'local', + base_url: 'https://chat.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['text_chat'] + }], + default_models: { primary: 'local-general-chat' }, + session_model_id: 'local-general-chat' + } + ]; + const state = new GeneralChatPageState(); + const controller = new SettingsController(store, state, { + probeConfiguration: async (_apiUrl: string, _apiKey: string, _modelName: string): Promise => { + } + }); + + const error = await controller.save( + 'https://chat.example.com', 'new-key', 'model-a', false + ); + + expect(error).assertEqual(''); + expect(store.saveRequests.length).assertEqual(1); + expect(store.selectLocalModelCalls).assertEqual(1); + expect(state.apiUrl).assertEqual('https://chat.example.com'); + expect(state.conversation.selectedModelId).assertEqual('local-general-chat'); + expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); + }); + }); + describe('GeneralChatModelSelectionPolicy', () => { it('keeps an existing cloud selection when a local model is saved', 0, () => { const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ From 61a957ba0a9f69b6843b7fb6ff98eabea85e0e2b Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 7 Aug 2026 17:22:23 +0800 Subject: [PATCH 045/206] chore(scripts): check HarmonyOS architecture boundaries The MVVM split only holds if the import direction is enforced. Add `pnpm run harmony:architecture`, which fails when services import pages, when components import view models, when the page graph gains a cycle, or when action and hook interfaces are passed as anything but typed object literals. Co-Authored-By: Claude Opus 5 --- package.json | 1 + scripts/check-harmonyos-architecture.mjs | 341 +++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 scripts/check-harmonyos-architecture.mjs diff --git a/package.json b/package.json index 71bceae0b0..0e6d177815 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", "check:build-prereqs": "node scripts/check-build-prereqs.mjs", + "harmony:architecture": "node scripts/check-harmonyos-architecture.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs new file mode 100644 index 0000000000..2ccdeff49b --- /dev/null +++ b/scripts/check-harmonyos-architecture.mjs @@ -0,0 +1,341 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const etsRoot = path.join(repoRoot, 'src/apps/mobile/harmonyos/entry/src/main/ets'); +const pagesRoot = path.join(etsRoot, 'pages'); + +function walkEts(root) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkEts(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.ets')) { + files.push(entryPath); + } + } + return files; +} + +function relative(file) { + return path.relative(repoRoot, file).split(path.sep).join('/'); +} + +function imports(file) { + const source = fs.readFileSync(file, 'utf8'); + const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1]); + return specs.map((spec) => { + if (!spec.startsWith('.')) { + return spec; + } + return path.relative(etsRoot, path.resolve(path.dirname(file), spec)).split(path.sep).join('/'); + }); +} + +function filesUnder(root) { + return walkEts(root).sort(); +} + +const allPages = filesUnder(pagesRoot); +const services = filesUnder(path.join(etsRoot, 'services')); +const components = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}components${path.sep}`)); +const viewmodels = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}viewmodel${path.sep}`)); + +const serviceToPages = services + .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) + .map(relative); +const componentToViewmodel = components + .filter((file) => imports(file).some((spec) => spec === 'pages/viewmodel' || spec.startsWith('pages/viewmodel/'))) + .map(relative); +const viewmodelToComponents = viewmodels + .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) + .map(relative); +const v1Components = allPages + .filter((file) => /^\s*@Component\s*$/m.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const positionalActionConstructors = allPages + .filter((file) => /export\s+class\s+\w+(?:Actions|Hooks)\b/.test(fs.readFileSync(file, 'utf8')) && + /\bconstructor\s*\(/.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const sharedConversationFields = [ + 'sessions', + 'activeSession', + 'persistedMessages', + 'optimisticMessages', + 'activeTurnMessage', + 'hasMoreMessages', + 'timelineItems', + 'timelineRevision', + 'isBusy', + 'modelCatalog', + 'selectedModelId', + 'statusText', + 'chatInput', + 'selectedImages', + 'isVoiceListening' +]; +const conversationPageStateFiles = [ + path.join(pagesRoot, 'state/GeneralChatPageState.ets'), + path.join(pagesRoot, 'state/RemotePageState.ets') +]; +const duplicatedConversationTraceFields = conversationPageStateFiles.flatMap((file) => { + const source = fs.readFileSync(file, 'utf8'); + return sharedConversationFields + .filter((field) => new RegExp(`@Trace\\s+${field}\\s*:`).test(source)) + .map((field) => `${relative(file)}:${field}`); +}); +const appRootRuntimeFile = path.join(pagesRoot, 'runtime/AppRootRuntime.ets'); +const appRootRuntimeSource = fs.readFileSync(appRootRuntimeFile, 'utf8'); +const appRootRuntimeLines = appRootRuntimeSource.split(/\r?\n/).length - 1; +const appRootPresentationFile = path.join(pagesRoot, 'components/AppRootPresentation.ets'); +const appRootPresentationSource = fs.readFileSync(appRootPresentationFile, 'utf8'); +const appRootPresentationLines = appRootPresentationSource.split(/\r?\n/).length - 1; +const requiredPresentationFiles = [ + 'components/AppRootOverlaySurfaces.ets', + 'components/ChatMessageChrome.ets', + 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationRouteSurface.ets', + 'components/ToolInteractionPanels.ets', + 'components/WideConversationHost.ets', + 'components/remote/RemoteSurfaceHost.ets' +]; +const missingPresentationFiles = requiredPresentationFiles + .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); +const componentLineBudgets = [ + ['components/ChatMessageBubble.ets', 1000], + ['components/ConnectView.ets', 700], + ['components/ToolStatusList.ets', 1120] +]; +const extractedFilePreviewMethods = [ + 'openFilePreview', + 'closeFilePreview', + 'refreshFilePreview', + 'openFilePreviewLink', + 'invalidateFilePreviewTarget' +].filter((method) => new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedSettingsMethods = [ + 'saveGeneralChatConfig', + 'testGeneralChatConfig', + 'validateGeneralChatConfig', + 'probeGeneralChatConfig', + 'effectiveGeneralChatApiKey', + 'applyGeneralChatConfig', + 'refreshGeneralChatModelCatalog' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedCloudAccountMethods = [ + 'persistDelegatedAccountSession', + 'loginCloudAccount', + 'restoreCloudAccountSession', + 'loadGeneralChatAccountModels', + 'syncCloudAccount', + 'applyCloudAccountSession', + 'logoutCloudAccount', + 'listCloudAccountDevices', + 'getRemotePermissionMode', + 'setRemotePermissionMode', + 'restoreCloudTarget', + 'expireCloudAccountSession', + 'handleRemoteConnectionError', + 'selectCloudAccountDevice' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+|protected\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedConversationMethods = [ + 'isGeneralComposerRoute', + 'visibleChatInput', + 'visibleSelectedImages', + 'visibleVoiceListening', + 'setChatInputForRoute', + 'setSelectedImagesForRoute', + 'addSelectedImagesForRoute', + 'removeSelectedImageForRoute', + 'clearComposerForRoute', + 'setVoiceListeningForRoute', + 'setAllVoiceListening', + 'voiceInputSnapshot', + 'visibleChatBusy', + 'visibleStatusText', + 'setVisibleStatusText' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConversationMethods = [ + 'sendChatMessage', + 'stopActiveTask', + 'renameActiveSession', + 'copyMessage', + 'downloadFile', + 'retryMessage', + 'approveTool', + 'rejectTool', + 'cancelTool', + 'answerQuestion', + 'resetChatTimeline', + 'syncChatTimelineFromStore', + 'startPolling', + 'currentChatPollingCursor', + 'updateChatPollingCursor', + 'applyChatSessionSnapshot', + 'hasRunningActiveTurn', + 'projectedTimelineItems', + 'syncAfterTurnEnded' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteCreateMethods = [ + 'createSession', + 'openRemoteCreateSession', + 'closeRemoteCreateSession', + 'loadRemoteCreateChoices', + 'loadRemoteCreateModelCatalog', + 'loadRemoteCreateDevices', + 'loadRemoteCreateWorkspaces', + 'toggleRemoteCreateDevices', + 'toggleRemoteCreateWorkspaces', + 'selectRemoteCreateDevice', + 'selectRemoteCreateWorkspace', + 'submitRemoteCreateSession', + 'createSessionInWorkspace', + 'openSession', + 'applyRemoteActiveSession', + 'deleteSession' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedGeneralConversationMethods = [ + 'openHomeSession', + 'openHomeSessionInPlace', + 'deleteHomeSession', + 'activeGeneralChatAsRemoteSession', + 'activeGeneralUploadedFileCount', + 'archiveHomeSession', + 'exportHomeSession', + 'openGeneralSession', + 'startGeneralChat', + 'sendVisibleChatMessage', + 'stopActiveChatTask', + 'closeActiveChat', + 'renameVisibleSession', + 'retryVisibleMessage', + 'downloadVisibleFile', + 'selectModel', + 'sendGeneralChatMessage', + 'stopGeneralChatStream', + 'startVisibleGeneralChat', + 'generalChatHomeStatusText', + 'prepareNewGeneralChat', + 'onVisibleChatInputChange', + 'visibleGeneralChatDraftId', + 'restoreGeneralChatDraft', + 'latestUserMessageText', + 'showHomeToast', + 'resetGeneralChatTimeline', + 'syncGeneralChatTimelineFromStore' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConnectionForwards = [ + 'applyWorkspace', + 'applyRemotePairingProjection', + 'ensureRemoteAvailable', + 'setRemoteConnectionState', + 'setRemoteUrl', + 'setRemoteUserId', + 'setRemoteAuthenticatedUserId', + 'setRemoteStatusText', + 'setRemoteConnectionFailureKind', + 'setRemoteBusy', + 'setRemoteUrlInputVisible' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const appRootRuntimeStateGetters = [ + 'remoteUrl', 'userId', 'authenticatedUserId', 'statusText', 'connectionState', + 'connectionFailureKind', 'isBusy', 'showRemoteUrlInput', 'workspaceName', 'workspacePath', + 'workspaceBranch', 'workspaceKind', 'assistantId', 'desktopName', 'desktopId', 'activeSession', + 'messages', 'pendingMessages', 'activeTurnMessage', 'timelineItems', 'hasMoreMessages' +].filter((getter) => new RegExp(`^\\s{2}get\\s+${getter}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedOwnerForwards = [ + 'currentRoute', 'isRoute', 'isGeneralChatVisible', 'pushRoute', 'replaceRoute', 'popRoute', + 'handleConversationIntent', 'pasteRemoteUrl', 'scanRemoteUrl', 'handleDetectedRemoteUrl', + 'showRecentWorkspaces', 'showAssistants', 'refreshSessions', 'loadMoreSessions', 'setSessionFilter', + 'openAddConnection', 'selectRemoteCreateModel', 'loadRecentWorkspacesInBackground', + 'loadOlderMessages', 'removeSelectedImage', 'persistVisibleGeneralChatDraft', 'stopPolling', + 'nudgeChatPolling', 'pollActiveSession', 'startHeartbeat', 'stopHeartbeat', + 'checkConnectionHealth', 'resumeRemoteActivity' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); + +const expected = { + serviceToPages: [], + componentToViewmodel: [], + viewmodelToComponents: [], + v1Components: [], + positionalActionConstructors: [], + duplicatedConversationTraceFields: [], + extractedFilePreviewMethods: [], + extractedSettingsMethods: [], + extractedCloudAccountMethods: [], + extractedConversationMethods: [], + extractedRemoteConversationMethods: [], + extractedRemoteCreateMethods: [], + extractedGeneralConversationMethods: [], + extractedRemoteConnectionForwards: [], + appRootRuntimeStateGetters: [], + extractedOwnerForwards: [], + missingPresentationFiles: [] +}; + +function sameSet(actual, wanted) { + return actual.length === wanted.length && actual.every((item, index) => item === wanted[index]); +} + +const actual = { + serviceToPages, + componentToViewmodel, + viewmodelToComponents, + v1Components, + positionalActionConstructors, + duplicatedConversationTraceFields, + extractedFilePreviewMethods, + extractedSettingsMethods, + extractedCloudAccountMethods, + extractedConversationMethods, + extractedRemoteConversationMethods, + extractedRemoteCreateMethods, + extractedGeneralConversationMethods, + extractedRemoteConnectionForwards, + appRootRuntimeStateGetters, + extractedOwnerForwards, + missingPresentationFiles +}; +let failed = false; +for (const [name, wanted] of Object.entries(expected)) { + if (!sameSet(actual[name], wanted)) { + failed = true; + console.error(`${name} mismatch`); + console.error(`expected: ${JSON.stringify(wanted)}`); + console.error(`actual: ${JSON.stringify(actual[name])}`); + } +} +if (appRootRuntimeLines > 500) { + failed = true; + console.error(`AppRootRuntime line budget exceeded: expected <=500, actual=${appRootRuntimeLines}`); +} +if (appRootPresentationLines > 500) { + failed = true; + console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); +} +for (const [file, budget] of componentLineBudgets) { + const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); + const lineCount = source.split(/\r?\n/).length - 1; + if (lineCount > budget) { + failed = true; + console.error(`${file} line budget exceeded: expected <=${budget}, actual=${lineCount}`); + } +} + +if (failed) { + process.exitCode = 1; +} else { + console.log('HarmonyOS architecture contracts are satisfied.'); +} From 3ef23d1c85837486ed5181aee026d6eba3e86567 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 11:32:47 +0800 Subject: [PATCH 046/206] feat(external-sources): improve application connection experience --- ...nal-ai-app-connection-experience-design.md | 2 +- ...ernal-ai-app-connection-experience-plan.md | 2 +- .../rules/source/public-api-rules.mjs | 17 + .../desktop/src/api/external_sources_api.rs | 72 ++++- .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 2 + .../assembly/core/src/external_sources.rs | 159 ++++++++++ .../scenes/settings/SettingsNav.appearance.ts | 1 + .../src/app/scenes/settings/SettingsNav.scss | 10 + .../src/app/scenes/settings/SettingsNav.tsx | 14 + .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsStore.ts | 19 ++ .../components/ChatInput.appearance.ts | 1 + .../src/flow_chat/components/ChatInput.scss | 24 ++ .../src/flow_chat/components/ChatInput.tsx | 23 +- .../utils/externalPromptCommands.test.ts | 94 ++++++ .../flow_chat/utils/externalPromptCommands.ts | 42 ++- .../service-api/ExternalSourcesAPI.test.ts | 23 ++ .../api/service-api/ExternalSourcesAPI.ts | 25 ++ .../ExternalSourcesConfig.appearance.ts | 4 + .../components/ExternalSourcesConfig.scss | 147 +++++++-- .../components/ExternalSourcesConfig.tsx | 261 ++++++---------- .../external-sources/ExternalAppDetail.tsx | 95 ++++++ .../external-sources/ExternalAppsOverview.tsx | 91 ++++++ .../ExternalCommandConflicts.tsx | 114 +++++++ .../ExternalSourceSection.tsx | 112 +++++++ .../external-sources/applicationModel.test.ts | 282 +++++++++++++++++ .../external-sources/applicationModel.ts | 290 ++++++++++++++++++ .../components/external-sources/index.ts | 23 ++ .../external-sources/presentation.ts | 69 +++++ .../components/external-sources/types.ts | 27 ++ .../useExternalAppAwareness.ts | 54 ++++ src/web-ui/src/locales/en-US/flow-chat.json | 4 + src/web-ui/src/locales/en-US/settings.json | 1 + .../en-US/settings/external-sources.json | 60 ++++ src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + src/web-ui/src/locales/zh-CN/settings.json | 1 + .../zh-CN/settings/external-sources.json | 60 ++++ src/web-ui/src/locales/zh-TW/flow-chat.json | 4 + src/web-ui/src/locales/zh-TW/settings.json | 1 + .../zh-TW/settings/external-sources.json | 60 ++++ 41 files changed, 2094 insertions(+), 210 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/index.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/types.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md index 30da8581d4..9a9fe20abd 100644 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ b/docs/architecture/extensions/external-ai-app-connection-experience-design.md @@ -167,7 +167,7 @@ Instruction、Skill、Hook 和显式复制成 BitFun 原生配置的内容继续 ### 5.1 首页 -首页沿用现有约 600px 正文最大宽度,按以下顺序纵向排列: +首页沿用现有 `ConfigPageLayout` 的 760px 正文最大宽度,按以下顺序纵向排列: 1. 标题和一句说明; 2. “需要处理”摘要,仅在有真实待办时显示; diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md index 48581faf84..0bfab7b5bf 100644 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ b/docs/plans/external-ai-app-connection-experience-plan.md @@ -398,7 +398,7 @@ pnpm run type-check:web - `ExternalAppReview`; - `ExternalAdvancedSettings`; - 无策略判断的 presentation helpers。 -3. 首页使用现有约 600px 单列阅读轴:标题、真实待办、应用列表、高级设置。 +3. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量。 4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。 5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。 6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;按类别展示数量、主要风险、共享推荐状态和安全上限,技术详情按需展开,高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 5df1dad4a0..f44871619e 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -1076,6 +1076,23 @@ export const externalSourceCorePublicApiEntries = [ 'Desktop external-source configuration host adapter', true, ), + ...[ + 'unacknowledged_external_ecosystems', + 'acknowledge_external_ecosystems', + ].map((symbol) => ({ + symbol, + owner: 'bitfun-core external source composition facade', + consumer: 'Desktop settings navigation and CLI/TUI external application entry points', + verification: + 'core acknowledgement persistence and execution-domain scoping tests, plus Desktop and TUI first-discovery hint tests', + p0: 'first-discovery hint for external applications shared by GUI and TUI', + contractSlice: contractSlices.externalSourceCommandContract, + wireImpact: true, + rationale: + 'both surfaces must derive "an external application the user has not seen" from one owner, otherwise GUI and TUI drift; awareness stays outside the preference-revision contract because it grants nothing and only suppresses a hint', + exit: + 'remove once the versioned application-level read model owns notice state, together with its cross-surface deduplication tests', + })), ...[ 'ExternalToolActivationState', 'ExternalToolApprovalRequest', diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index d1bf91b2c5..0890159bac 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,21 +1,23 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_location_for_host_action, external_source_snapshot, + acknowledge_external_ecosystems, apply_external_source_control_action, + choose_external_mcp_conflict, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_location_for_host_action, + external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_subagent_model_binding, set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, update_external_integration_policy, - workspace_reference_snapshot, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, - ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget, - NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, - PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, + set_native_prompt_command_conflict_choice, unacknowledged_external_ecosystems, + update_external_integration_policy, workspace_reference_snapshot, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, + ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot, + NativePromptCommandDescriptor, PromptCommandInvocationOutcome, + PromptCommandShellReviewDecision, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::{ @@ -73,6 +75,25 @@ pub struct RevealExternalSourceLocationRequest { pub source_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalEcosystemAwarenessRequest { + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEcosystemAwarenessResponse { + pub unacknowledged_ecosystem_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AcknowledgeExternalEcosystemsRequest { + pub workspace_path: Option, + pub ecosystem_ids: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateExternalIntegrationPolicyRequest { @@ -407,6 +428,37 @@ pub async fn apply_external_source_control_action_command( apply_external_source_control_action(workspace, request.control).await } +/// External applications discovered on this host that the user has never been +/// told about. Surfaces use it to show a low-key "something new" affordance. +#[tauri::command] +pub async fn get_external_ecosystem_awareness_command( + request: ExternalEcosystemAwarenessRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + unacknowledged_external_ecosystems(workspace) + .await + .map( + |unacknowledged_ecosystem_ids| ExternalEcosystemAwarenessResponse { + unacknowledged_ecosystem_ids, + }, + ) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + +/// Records that the user has seen these external applications. +/// +/// This only clears the "new application" hint. It grants nothing, so it takes +/// no expected preference revision and leaves approvals and policy untouched. +#[tauri::command] +pub async fn acknowledge_external_ecosystems_command( + request: AcknowledgeExternalEcosystemsRequest, +) -> ExternalSourceOperationResult<()> { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + acknowledge_external_ecosystems(workspace, request.ecosystem_ids) + .await + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 119858ac6d..3d02f0b0d8 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -201,6 +201,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "apply_external_source_control_action_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_external_ecosystem_awareness_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "acknowledge_external_ecosystems_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited), ( "archive_all_sessions", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index af8fc576fa..3d292941c2 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1245,6 +1245,8 @@ pub async fn run() { reveal_external_source_location, get_external_source_control_snapshot, apply_external_source_control_action_command, + get_external_ecosystem_awareness_command, + acknowledge_external_ecosystems_command, update_external_integration_policy_command, set_external_source_enabled_command, set_external_source_conflict_choice_command, diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 97b20519e0..7bea9318be 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -139,6 +139,10 @@ const MAX_PROMPT_COMMAND_SHELL_OUTPUT_CHARS: usize = 256 * 1024; const PROMPT_COMMAND_SHELL_TIMEOUT_MS: u64 = 30_000; const PROMPT_COMMAND_SHELL_KILL_YIELD_MS: u64 = 5_000; const MAX_APPROVED_PROMPT_COMMAND_SHELL_PLANS: usize = 512; +/// Awareness records are tiny and bounded by the number of registered +/// ecosystems, but the cap keeps a corrupted or hostile file from growing +/// without limit. +const MAX_ACKNOWLEDGED_ECOSYSTEMS: usize = 256; #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedPromptCommandShell { @@ -922,6 +926,17 @@ struct ExternalSourcesConfig { mcp_server_decisions: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] mcp_conflict_choices: BTreeMap, + /// Ecosystems the user has already been told about, as + /// `execution_domain_id` + unit separator + `ecosystem_id`. + /// + /// This records awareness, not a policy decision: it only suppresses the + /// "a new external application was found" hint. It deliberately carries no + /// content version, because discovering more commands inside an ecosystem + /// the user already knows about is not new information. Awareness is also + /// user-wide rather than per workspace, so opening another project does not + /// re-announce the same application. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + acknowledged_ecosystems: BTreeSet, /// Preserves fields written by a newer preferences schema. #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, @@ -971,6 +986,7 @@ impl std::fmt::Debug for ExternalSourcesConfig { .field("subagent_model_bindings", &self.subagent_model_bindings) .field("mcp_server_decisions", &self.mcp_server_decisions) .field("mcp_conflict_choices", &self.mcp_conflict_choices) + .field("acknowledged_ecosystems", &self.acknowledged_ecosystems) .field("extensions", &self.extensions) .finish() } @@ -4579,6 +4595,88 @@ async fn read_external_sources_config() -> Result ExternalSourcePreferenceStore::global()?.read().await } +fn acknowledged_ecosystem_key(execution_domain_id: &str, ecosystem_id: &str) -> String { + format!("{execution_domain_id}\u{1f}{ecosystem_id}") +} + +/// Ecosystems that have configuration on this host but have never been +/// announced to the user. +/// +/// Both the desktop settings navigation and the TUI read this same result, so +/// neither surface derives "is there something new" on its own and they cannot +/// drift apart. An ecosystem only qualifies once discovery actually found a +/// source for it: a registered adapter with nothing to offer is not news. +pub async fn unacknowledged_external_ecosystems( + workspace_root: Option<&Path>, +) -> Result, String> { + let service = read_only_service_for(workspace_root).await?; + let execution_domain_id = service.execution_domain_id.clone(); + let discovered = service + .snapshot() + .sources + .iter() + .map(|source| source.record.ecosystem_id.to_string()) + .collect::>(); + if discovered.is_empty() { + return Ok(Vec::new()); + } + let config = read_external_sources_config().await?; + Ok(discovered + .into_iter() + .filter(|ecosystem_id| { + !config + .acknowledged_ecosystems + .contains(&acknowledged_ecosystem_key( + execution_domain_id.as_str(), + ecosystem_id, + )) + }) + .collect()) +} + +/// Records that the user has seen the given ecosystems. +/// +/// Awareness is not part of the preference-revision contract. The set only +/// grows, insertion is idempotent, and no policy or approval decision reads it, +/// so concurrent writers cannot lose each other's decisions here. Taking an +/// expected revision would therefore add fencing failures without protecting +/// anything, and bumping the revision would invalidate unrelated in-flight +/// mutations every time a user opens the settings page. +/// +/// The execution domain is resolved from the workspace's own service so hosts +/// never pass an identity that disagrees with the one discovery recorded. +pub async fn acknowledge_external_ecosystems( + workspace_root: Option<&Path>, + ecosystem_ids: Vec, +) -> Result<(), String> { + if ecosystem_ids.is_empty() { + return Ok(()); + } + let execution_domain_id = read_only_service_for(workspace_root) + .await? + .execution_domain_id + .clone(); + let keys = ecosystem_ids + .iter() + .map(|ecosystem_id| acknowledged_ecosystem_key(execution_domain_id.as_str(), ecosystem_id)) + .collect::>(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + for key in &keys { + if config.acknowledged_ecosystems.contains(key) { + continue; + } + if config.acknowledged_ecosystems.len() >= MAX_ACKNOWLEDGED_ECOSYSTEMS { + break; + } + config.acknowledged_ecosystems.insert(key.clone()); + } + true + }) + .await + .map(|_| ()) +} + async fn persist_prompt_command_shell_plan_approval( fingerprint: &str, expected_preference_revision: u64, @@ -8723,6 +8821,67 @@ mod tests { ); } + #[tokio::test] + async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let store = ExternalSourcePreferenceStore::new(path.clone()); + let key = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode"); + + store + .update(|config| { + config.acknowledged_ecosystems.insert(key.clone()); + }) + .await + .unwrap(); + store + .update(|config| { + config.acknowledged_ecosystems.insert(key.clone()); + }) + .await + .unwrap(); + + // A fresh store proves the record came back from disk, not from memory. + let reloaded = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + assert_eq!(reloaded.acknowledged_ecosystems, BTreeSet::from([key])); + // Awareness is not a policy decision, so it must not consume a revision. + assert_eq!(reloaded.preference_revision, 0); + } + + #[tokio::test] + async fn acknowledgement_is_scoped_to_its_execution_domain() { + let temp = tempfile::tempdir().unwrap(); + let store = ExternalSourcePreferenceStore::new(temp.path().join("external-sources.json")); + let local = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode"); + let remote = acknowledged_ecosystem_key("remote-host", "opencode"); + + store + .update(|config| { + config.acknowledged_ecosystems.insert(local.clone()); + }) + .await + .unwrap(); + + let persisted = store.read().await.unwrap(); + assert!(persisted.acknowledged_ecosystems.contains(&local)); + assert!(!persisted.acknowledged_ecosystems.contains(&remote)); + } + + #[test] + fn acknowledgement_keys_never_collide_across_domains_or_ecosystems() { + assert_ne!( + acknowledged_ecosystem_key("local-user", "opencode"), + acknowledged_ecosystem_key("local-user", "codex") + ); + assert_ne!( + acknowledged_ecosystem_key("local-user", "opencode"), + acknowledged_ecosystem_key("remote-host", "opencode") + ); + } + #[test] fn opencode_registry_owns_low_friction_defaults_and_safety_ceilings() { let mut config = ExternalSourcesConfig::default(); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts index f7a615b167..687458bdef 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts @@ -14,6 +14,7 @@ export const settingsNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'categoryHeader', visualRole: 'toolbar' }, { id: 'items', visualRole: 'content' }, { id: 'item', propertyProfile: 'control', visualRole: 'control' }, + { id: 'itemUnseen', propertyProfile: 'paint', visualRole: 'decoration' }, { id: 'highlight', propertyProfile: 'paint', visualRole: 'decoration' }, ], states: [ diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.scss b/src/web-ui/src/app/scenes/settings/SettingsNav.scss index 39563c5559..4ea407ef1e 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.scss @@ -226,6 +226,16 @@ text-transform: uppercase; } + /* Marks a tab holding something the user has not seen yet. */ + &__item-unseen { + flex-shrink: 0; + width: 6px; + height: 6px; + margin-left: $size-gap-2; + border-radius: 50%; + background: var(--bf-appearance-token-color-accent-500); + } + &__item { display: flex; align-items: center; diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index e9022ba8b7..e9ce3935ad 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -143,6 +143,7 @@ function useSettingsNav() { const setActiveTab = useSettingsStore((s) => s.setActiveTab); const searchQuery = useSettingsStore((s) => s.searchQuery); const setSearchQuery = useSettingsStore((s) => s.setSearchQuery); + const unseenTabs = useSettingsStore((s) => s.unseenTabs); const [draftQuery, setDraftQuery] = useState(''); const searchInputRef = useRef(null); @@ -276,6 +277,7 @@ function useSettingsNav() { return { t, activeTab, + unseenTabs, handleTabClick, preloadTab, draftQuery, @@ -298,6 +300,7 @@ const SettingsNav: React.FC = () => { const { t, activeTab, + unseenTabs, handleTabClick, preloadTab, draftQuery, @@ -436,6 +439,17 @@ const SettingsNav: React.FC = () => { {t(tabDef.labelKey, { defaultValue: tabDef.id })} + {unseenTabs.includes(tabDef.id) ? ( + + ) : null} {tabDef.beta ? ( {t('configCenter.beta')} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index f3dc9ad3fc..5ae668e623 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -12,6 +12,7 @@ import React, { useState, } from 'react'; import { useSettingsStore } from './settingsStore'; +import { useExternalAppAwareness } from '@/infrastructure/config/components/external-sources'; import type { ConfigTab } from './settingsConfig'; import { AcpAgentsConfig, @@ -77,6 +78,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { } const SettingsScene: React.FC = () => { + useExternalAppAwareness(); const activeTab = useSettingsStore(s => s.activeTab); const setActiveTab = useSettingsStore(s => s.setActiveTab); diff --git a/src/web-ui/src/app/scenes/settings/settingsStore.ts b/src/web-ui/src/app/scenes/settings/settingsStore.ts index 0a468f17b2..2bc1670d6b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsStore.ts +++ b/src/web-ui/src/app/scenes/settings/settingsStore.ts @@ -15,14 +15,33 @@ interface SettingsState { /** Debounced from SettingsNav search input; used for filtering index. */ searchQuery: string; setSearchQuery: (query: string) => void; + /** + * Tabs with something the user has not seen yet, rendered as a small dot. + * + * The navigation stays feature-neutral: a tab owner decides when it has + * unseen content and writes the id here, rather than SettingsNav learning to + * query each feature. + */ + unseenTabs: ConfigTab[]; + markTabUnseen: (tab: ConfigTab, unseen: boolean) => void; } export const useSettingsStore = create((set) => ({ activeTab: DEFAULT_SETTINGS_TAB, searchQuery: '', + unseenTabs: [], setActiveTab: (tab) => set({ activeTab: tab }), setSearchQuery: (query) => set({ searchQuery: query }), + markTabUnseen: (tab, unseen) => set((state) => { + const has = state.unseenTabs.includes(tab); + if (has === unseen) return state; + return { + unseenTabs: unseen + ? [...state.unseenTabs, tab] + : state.unseenTabs.filter((candidate) => candidate !== tab), + }; + }), })); /** Resolve the category id for a given tab (for initial scroll / highlight) */ diff --git a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts index 271ed97270..205c6ff71a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts @@ -23,6 +23,7 @@ export const chatInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'commandName' }, { id: 'commandLabel' }, { id: 'commandCurrent' }, + { id: 'commandStatus' }, { id: 'commandSection' }, { id: 'commandEmpty' }, { id: 'actions' }, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 71da60a4d0..0283dffe10 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -1738,6 +1738,30 @@ flex: 0 0 auto; } + /* + * External commands that cannot run yet. The badge keeps the reason visible + * in the list instead of waiting for the click that reveals it, matching the + * hints the TUI command menu appends to its descriptions. + */ + &__slash-command-status { + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + padding: 0.12rem 0.32rem; + border-radius: 3px; + font-weight: 500; + flex: 0 0 auto; + white-space: nowrap; + + &--restricted { + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 15%, transparent); + color: color-mix(in srgb, var(--bf-appearance-token-color-warning) 90%, var(--bf-appearance-token-color-text-primary)); + } + + &--choose { + background: var(--bf-appearance-token-color-bg-tertiary); + color: var(--bf-appearance-token-color-text-secondary); + } + } + &__slash-command-empty { padding: var(--bf-appearance-token-size-gap-4) var(--bf-appearance-token-flowchat-card-expanded-pad-x); text-align: center; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ad471a2378..cc2ce0daf9 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -5425,15 +5425,32 @@ export const ChatInput: React.FC = ({ {labelText} {item.kind === 'mode' && item.id === modeState.current && {t('chatInput.current')}} + {item.kind === 'externalCommand' && item.status !== 'available' ? ( + + {t(item.status === 'restricted' + ? 'chatInput.commandStatus.restricted' + : 'chatInput.commandStatus.chooseSource')} + + ) : null}
); }) - ) : !externalPromptCommandsIssue ? ( + ) : (
- {t('chatInput.noMatchingCommand')} + {/* A catalog issue must not leave the list blank: say why nothing is listed. */} + {externalPromptCommandsIssue === 'host_unavailable' + ? t('chatInput.externalCommandsHostUnavailable') + : externalPromptCommandsIssue === 'load_failed' + ? t('chatInput.externalCommandsLoadFailed') + : t('chatInput.noMatchingCommand')}
- ) : null} + )} ); diff --git a/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts b/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts index a9e9dc0568..33163e2918 100644 --- a/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts +++ b/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts @@ -128,6 +128,100 @@ describe('external prompt command projection', () => { expect(items.every(item => item.conflictKey === 'review-conflict')).toBe(true); expect(items.every(item => item.expectedPreferenceRevision === 9)).toBe(true); }); + + it('carries the owning ecosystem so the picker can name the application', () => { + expect(buildExternalPromptCommandItems(snapshot())[0]).toMatchObject({ + ecosystemId: 'claude-code', + status: 'available', + }); + }); + + it('asks the user to pick a source while a conflict is unresolved', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + candidates: [ + { + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { state: 'available' }, + }, + { + candidateId: 'opencode-review', + source: { providerId: 'opencode.commands', sourceId: 'project' }, + sourceDisplayName: 'OpenCode project commands', + ecosystemId: 'opencode', + contentVersion: 'opencode-v1', + commandDescription: 'Review with OpenCode conventions', + sourceScope: 'project', + sourceLocation: '.opencode/commands', + availability: { state: 'available' }, + }, + ], + }], + })); + + expect(items.map(item => item.status)).toEqual(['choose_source', 'choose_source']); + expect(items.map(item => item.ecosystemId)).toEqual(['claude-code', 'opencode']); + }); + + it('drops the pick-a-source hint once the conflict is resolved', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + selectedCandidateId: 'claude-review', + candidates: [{ + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { state: 'available' }, + }], + }], + })); + + expect(items.map(item => item.status)).toEqual(['available']); + }); + + it('reports a policy-blocked command as restricted instead of asking for a source', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + candidates: [{ + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { + state: 'restricted', + reason: 'External command execution is disabled by integration policy', + }, + }], + }], + })); + + expect(items[0]).toMatchObject({ status: 'restricted', available: false }); + }); }); describe('external prompt command invocation resolution', () => { diff --git a/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts b/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts index efa6cbe7f5..f63832868e 100644 --- a/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts +++ b/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts @@ -3,6 +3,18 @@ import type { PromptCommandAvailability, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +/** + * Why a command needs a decision before it can run. + * + * Mirrors the states the TUI command menu already surfaces so both entry + * points describe the same catalog facts. Each surface renders them with its + * own copy and layout; only the derivation is shared, through the snapshot. + */ +export type ExternalPromptCommandStatus = + | 'available' + | 'restricted' + | 'choose_source'; + export interface ExternalPromptCommandItem { id: string; command: string; @@ -13,6 +25,9 @@ export interface ExternalPromptCommandItem { unavailableReason?: string; conflictKey?: string; expectedPreferenceRevision?: number; + /** Owning ecosystem, so the list can name the application behind a command. */ + ecosystemId?: string; + status: ExternalPromptCommandStatus; } export type ExternalPromptCommandInvocation = @@ -80,6 +95,16 @@ function availabilityFacts(availability: PromptCommandAvailability): { }; } +/** + * Restricted wins over conflict: a command the policy already blocks cannot be + * fixed by picking a source, so showing "choose this source" would be a dead + * end. This ordering matches the TUI command menu. + */ +function commandStatus(available: boolean, hasConflict: boolean): ExternalPromptCommandStatus { + if (!available) return 'restricted'; + return hasConflict ? 'choose_source' : 'available'; +} + export function buildExternalPromptCommandItems( snapshot: ExternalSourceCatalogSnapshot, ): ExternalPromptCommandItem[] { @@ -89,6 +114,12 @@ export function buildExternalPromptCommandItems( source.record.displayName, ]), ); + const sourceEcosystems = new Map( + snapshot.sources.map(source => [ + `${source.record.key.providerId}:${source.record.key.sourceId}`, + source.record.ecosystemId, + ]), + ); const items = new Map(); for (const entry of snapshot.commands) { @@ -101,13 +132,16 @@ export function buildExternalPromptCommandItems( } const sourceKey = `${definition.id.source.providerId}:${definition.id.source.sourceId}`; const sourceLabel = sourceLabels.get(sourceKey) ?? definition.id.source.providerId; + const facts = availabilityFacts(definition.availability); items.set(candidateId, { id: candidateId, command: `/${definition.name}`, label: `${definition.description || definition.name} · ${sourceLabel}`, candidateId, contentVersion: definition.contentVersion, - ...availabilityFacts(definition.availability), + ecosystemId: sourceEcosystems.get(sourceKey), + status: commandStatus(facts.available, false), + ...facts, }); } @@ -119,6 +153,7 @@ export function buildExternalPromptCommandItems( if (items.has(candidate.candidateId)) { continue; } + const facts = availabilityFacts(candidate.availability); items.set(candidate.candidateId, { id: candidate.candidateId, command: `/${conflict.commandName}`, @@ -127,7 +162,10 @@ export function buildExternalPromptCommandItems( contentVersion: candidate.contentVersion, conflictKey: conflict.conflictKey, expectedPreferenceRevision: snapshot.preferenceRevision ?? 0, - ...availabilityFacts(candidate.availability), + ecosystemId: candidate.ecosystemId, + // A resolved conflict no longer asks the user to pick a source. + status: commandStatus(facts.available, !conflict.selectedCandidateId), + ...facts, }); } } diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index 066b986d20..c80b1ea376 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -66,6 +66,29 @@ describe('ExternalSourcesAPI', () => { adapterMocks.isConnected.mockReturnValue(true); }); + it('reads and acknowledges backend-owned ecosystem awareness', async () => { + invokeMock + .mockResolvedValueOnce({ unacknowledgedEcosystemIds: ['opencode', 'codex'] }) + .mockResolvedValueOnce(undefined); + + await expect(externalSourcesAPI.getEcosystemAwareness('D:/workspace/project')) + .resolves.toEqual(['opencode', 'codex']); + await externalSourcesAPI.acknowledgeEcosystems( + 'D:/workspace/project', + ['opencode', 'codex'], + ); + + expect(invokeMock).toHaveBeenNthCalledWith(1, 'get_external_ecosystem_awareness_command', { + request: { workspacePath: 'D:/workspace/project' }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'acknowledge_external_ecosystems_command', { + request: { + workspacePath: 'D:/workspace/project', + ecosystemIds: ['opencode', 'codex'], + }, + }); + }); + it('keeps workspace ownership and refresh intent in the public snapshot request', async () => { await externalSourcesAPI.getSnapshot('D:/workspace/project', true); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 690966392f..05e0dd3218 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -1680,4 +1680,29 @@ export const externalSourcesAPI = { emitExternalAgentCatalogUpdated(workspacePath); return catalog; }, + + /** + * External applications found on this host that the user has never been told + * about. The host owns this derivation so the desktop and the TUI cannot + * disagree about what counts as new. + */ + async getEcosystemAwareness(workspacePath?: string): Promise { + const response = await invokeExternalSourceCommand<{ + unacknowledgedEcosystemIds?: unknown; + }>('get_external_ecosystem_awareness_command', { + request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath) }, + }); + return normalizeOptionalArray(response.unacknowledgedEcosystemIds) + .filter((ecosystemId): ecosystemId is string => typeof ecosystemId === 'string'); + }, + + /** Clears the "new external application" hint for these ecosystems. */ + acknowledgeEcosystems(workspacePath: string | undefined, ecosystemIds: string[]) { + return invokeExternalSourceCommand('acknowledge_external_ecosystems_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + ecosystemIds, + }, + }); + }, }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts index 51472729d1..59aab1704a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -33,5 +33,9 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemHeading' }, { id: 'ecosystemName' }, { id: 'ecosystemState' }, + { id: 'application' }, + { id: 'appDetail' }, + { id: 'appAttention' }, + { id: 'appCapability' }, ], }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index dbf964ebce..81c07748ee 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -2,6 +2,28 @@ container-name: external-sources; container-type: inline-size; + &__advanced { + margin-top: var(--bf-appearance-token-size-gap-5); + border-top: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__advanced-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-3); + padding: 14px 2px; + color: var(--bf-appearance-token-color-text-primary); + font-size: 13px; + font-weight: 600; + + span:last-child { + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + font-weight: 400; + } + } + details > summary { cursor: pointer; user-select: none; @@ -52,6 +74,92 @@ padding-left: var(--bf-appearance-token-size-gap-4); } + &__app-detail { + display: grid; + gap: var(--bf-appearance-token-size-gap-4); + } + + &__app-detail-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-4); + + h2 { margin: 0; color: var(--bf-appearance-token-color-text-primary); font-size: 20px; } + p { margin: 5px 0 0; color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; } + small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-muted); font-size: 11px; } + } + + &__app-attention { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-3); + width: 100%; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); + border-radius: var(--bf-appearance-token-size-radius-sm); + color: var(--bf-appearance-token-color-warning); + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); + text-align: left; + cursor: pointer; + + small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-secondary); } + } + + &__app-capabilities { overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: var(--bf-appearance-token-size-radius-md); } + &__app-capability { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 14px; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + + &:last-child { border-bottom: 0; } + strong, small { display: block; } + strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } + small { margin-top: 3px; } + } + + + display: grid; + overflow: hidden; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: var(--bf-appearance-token-size-radius-md); + } + + &__app-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-4); + padding: 14px var(--bf-appearance-token-size-gap-4); + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + + &:last-child { border-bottom: 0; } + } + + &__app-copy { min-width: 0; } + &__app-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--bf-appearance-token-size-gap-2); + } + &__app-name { color: var(--bf-appearance-token-color-text-primary); font-weight: 600; } + &__app-status, &__app-summary { + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + } + &__app-status { + &.is-connected, &.is-connected_custom { color: var(--bf-appearance-token-color-success); } + &.is-needs_attention { color: var(--bf-appearance-token-color-warning); } + } + &__app-summary { margin-top: 4px; overflow-wrap: anywhere; } + &__ecosystem-heading, &__policy-actions, &__ecosystem-name { @@ -103,7 +211,7 @@ display: inline-flex; align-items: center; width: fit-content; - font-size: 10px; + font-size: 12px; font-weight: 500; } @@ -209,7 +317,7 @@ align-items: center; gap: 4px; color: var(--bf-appearance-token-color-text-secondary); - font-size: 10px; + font-size: 12px; font-weight: 500; &.is-ready { @@ -398,7 +506,7 @@ } .bitfun-switch__description { - font-size: 10px; + font-size: 12px; line-height: 13px; } } @@ -434,7 +542,7 @@ border-radius: var(--bf-appearance-token-size-radius-sm); background: var(--bf-appearance-token-color-bg-tertiary); color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; font-variant-numeric: tabular-nums; line-height: 16px; white-space: nowrap; @@ -442,7 +550,7 @@ &__state { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; &.is-using_last_valid_version, &.is-restricted, @@ -453,19 +561,22 @@ } } + &__conflict, + &__tool-card, + &__opencode-card { + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: var(--bf-appearance-token-size-radius-sm); + background: var(--bf-appearance-token-color-bg-tertiary); + } + &__conflict { - padding: 12px 0; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + padding: 12px 14px; - &:last-child { - border-bottom: 0; - } + & + & { margin-top: var(--bf-appearance-token-size-gap-3); } } &__tool-card { - padding: 10px 14px; - border-left: 2px solid var(--bf-appearance-token-border-medium); - background: transparent; + padding: 12px 14px; overflow-wrap: anywhere; & + & { @@ -499,8 +610,6 @@ &__opencode-card { padding: 12px 14px; - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: 6px; margin-bottom: 12px; } @@ -570,7 +679,7 @@ &__tool-warning { margin-top: 8px; color: var(--bf-appearance-token-color-warning); - font-size: 11px; + font-size: 12px; } &__review-summary { @@ -604,7 +713,7 @@ &__diagnostic-code { color: var(--bf-appearance-token-color-text-muted); - font-size: 11px; + font-size: 12px; overflow-wrap: anywhere; } @@ -637,12 +746,12 @@ &__candidate-detail { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; } &__candidate-state { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; } &__ecosystem { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index f913c8bb90..68e6ed323e 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -57,15 +57,17 @@ import { type ExternalSourcePresentationGroup, } from '../externalSourcePresentation'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; +import { + ExternalAppDetail, + ExternalAppsOverview, + ExternalCommandConflicts, + ExternalSourceSection, + buildExternalApplicationsView, + type ExternalApplicationView, +} from './external-sources'; import './ExternalSourcesConfig.scss'; const DISCOVERY_POLL_DELAYS_MS = [750, 1_500, 3_000, 5_000] as const; -const SOURCE_COUNT_LABELS = [ - ['commands', 'sources.commandCount'], - ['tools', 'sources.toolCount'], - ['agents', 'sources.agentCount'], - ['mcps', 'sources.mcpCount'], -] as const; const AGENT_DIAGNOSTIC_SETTING_KEYS: Record = { opencode_unknown_agent_field: 'unknownField', @@ -388,6 +390,9 @@ const ExternalSourcesConfig: React.FC = () => { preferenceRevision: number; } | null>(null); const [agentChangeNotice, setAgentChangeNotice] = useState(null); + const [connectingApplication, setConnectingApplication] = useState(null); + const [selectedApplicationId, setSelectedApplicationId] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); const snapshotRef = useRef(null); const agentChangeNoticeRef = useRef(null); const requestSequence = useRef(0); @@ -625,6 +630,13 @@ const ExternalSourcesConfig: React.FC = () => { () => snapshot ? catalogDiagnosticsWithoutSourceDuplicates(snapshot, sourceGroups) : [], [snapshot, sourceGroups], ); + const applicationsView = useMemo( + () => buildExternalApplicationsView(snapshot, sourceGroups, catalogDiagnostics.length, policyScope), + [catalogDiagnostics.length, policyScope, snapshot, sourceGroups], + ); + const selectedApplication = applicationsView.applications.find( + (application) => application.ecosystemId === selectedApplicationId, + ) ?? null; const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), @@ -1079,6 +1091,18 @@ const ExternalSourcesConfig: React.FC = () => { ); }, [policyScope, runMutation, snapshot, t, workspacePath]); + const connectApplication = useCallback(async () => { + const application = connectingApplication; + if (!application || !snapshot) return; + setConnectingApplication(null); + const accepted = await updatePolicy({ + operation: 'set_ecosystem_mode', + ecosystemId: application.ecosystemId, + mode: 'recommended', + }); + if (accepted) setOperationStatus(t('applications.connectionComplete')); + }, [connectingApplication, snapshot, t, updatePolicy]); + const updateCapabilityAccess = useCallback(( ecosystemId: string, capabilityId: string, @@ -1424,6 +1448,33 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} ) : null} + {snapshot && selectedApplication ? ( + setSelectedApplicationId(null)} + onOpenAdvanced={() => { + setAdvancedOpen(true); + window.requestAnimationFrame(scrollToFirstAttentionItem); + }} + /> + ) : snapshot ? ( + setConnectingApplication(application)} + onManage={(application) => setSelectedApplicationId(application.ecosystemId)} + /> + ) : null} +
setAdvancedOpen(event.currentTarget.open)} + > + + {t('applications.advanced.title')} + {t('applications.advanced.description')} + {snapshot && policy ? ( { ) : null} - {nonOpencodeGroups.length > 0 ? ( - - {nonOpencodeGroups.map((group) => { - return ( - - - - - {group.location} - - - - {group.scopes.map((scope, index) => ( - - {index > 0 ? : null} - - {sourceScopeLabel(scope, t)} - - - ))} - - - {SOURCE_COUNT_LABELS.some( - ([capability]) => group.counts[capability] > 0, - ) ? ( - - {SOURCE_COUNT_LABELS.map(([capability, label]) => { - const count = group.counts[capability]; - return count > 0 ? ( - - {t(label, { count })} - - ) : null; - })} - - ) : null} - - )} - align="center" - > - {renderSourceMembers(group)} - - {group.diagnostics.length > 0 ? ( -
- - {t('diagnostics.sourceSummary', { - name: group.displayName, - count: group.diagnostics.length, - })} - -
    - {group.diagnostics.map((diagnostic) => ( -
  • - {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
    - {t('common.technicalDetails')} - {diagnostic.code} -
    -
  • - ))} -
-
- ) : null} -
- ); - })} -
- ) : null} + {(snapshot?.tools?.length ?? 0) > 0 ? ( @@ -3113,90 +3085,16 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} - {commandConflicts.length > 0 ? ( - - {commandConflicts.map((conflict) => { - const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( - candidate.candidateId === conflict.selectedCandidateId - && candidate.availability.state !== 'available' - )); - return ( -
-
- {t('conflicts.commandName', { name: conflict.commandName })} -
-
- {conflict.candidates.map((candidate) => { - const selected = conflict.selectedCandidateId === candidate.candidateId; - const available = candidate.availability.state === 'available'; - return ( -
- - - {t(selected - ? selectedChoiceUnavailable - ? 'common.selectedUnavailable' - : 'common.selected' - : !available - ? 'conflicts.restricted' - : conflict.selectedCandidateId - ? 'common.notSelected' - : 'common.availableChoice')} - -
- {candidate.commandDescription} - {' · '} - {sourceScopeLabel(candidate.sourceScope, t)} - {' · '} - - {abbreviatedLocation(candidate.sourceLocation)} - - {!available ? ` · ${t('conflicts.restricted')}` : ''} -
-
- ); - })} -
-
- {conflict.selectedCandidateId - ? t(selectedChoiceUnavailable - ? 'conflicts.currentSelectionUnavailable' - : 'conflicts.currentSelection') - : t('conflicts.pending')} -
-
- ); - })} -
- ) : null} + { + void chooseConflict(conflictKey, candidateId); + }} + /> {toolConflicts.length > 0 ? ( { })} ) : null} +
)} + setConnectingApplication(null)} + onConfirm={() => void connectApplication()} + title={connectingApplication ? t('applications.connectTitle', { name: connectingApplication.displayName }) : ''} + message={connectingApplication ? t('applications.connectMessage', { + commands: connectingApplication.counts.commands, + tools: connectingApplication.counts.tools, + agents: connectingApplication.counts.agents, + mcps: connectingApplication.counts.mcps, + }) : ''} + type="info" + confirmText={t('applications.actions.connect')} + /> setResetPolicyConfirmation(null)} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx new file mode 100644 index 0000000000..e805f980d8 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import { ConfigPageSection } from '../common'; +import type { ExternalApplicationView } from './applicationModel'; +import type { TFunction } from 'i18next'; + +export interface ExternalAppDetailProps { + application: ExternalApplicationView; + t: TFunction; + onBack: () => void; + onOpenAdvanced: () => void; +} + +const CAPABILITIES = [ + ['commands', 'commands'], + ['tools', 'tools'], + ['agents', 'agents'], + ['mcps', 'mcps'], +] as const; + +/** + * Result-first application detail. V1 can summarize what was discovered and + * what needs review; the existing capability controls remain reachable through + * Advanced settings until the versioned review model can filter every owner by + * application without guessing. + */ +export const ExternalAppDetail: React.FC = ({ + application, + t, + onBack, + onOpenAdvanced, +}) => ( +
+ +
+
+

{application.displayName}

+

{t(`applications.status.${application.status}`)}

+ {application.sourceCount > 0 ? ( + + {t('applications.detail.sourceSummary', { count: application.sourceCount })} + + ) : null} +
+ +
+ + {application.attentionCount > 0 ? ( + + ) : null} + + +
+ {CAPABILITIES.map(([field, label]) => { + const count = application.counts[field]; + return ( +
+ + {t(`applications.detail.capabilities.${label}`)} + {t('applications.detail.foundCount', { count })} + + + {application.connectPlan.find((entry) => entry.capabilityId === ( + field === 'commands' ? 'command' + : field === 'tools' ? 'tool' + : field === 'agents' ? 'subagent' + : 'mcp' + ))?.recommendedAccess === 'auto' + ? t('applications.detail.autoAvailable') + : t('applications.detail.managed')} + +
+ ); + })} +
+
+
+); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx new file mode 100644 index 0000000000..23519d170c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import { ConfigPageSection } from '../common'; +import type { ExternalApplicationView } from './applicationModel'; +import type { TFunction } from 'i18next'; + +export interface ExternalAppsOverviewProps { + applications: ExternalApplicationView[]; + t: TFunction; + onConnect: (application: ExternalApplicationView) => void; + onManage: (application: ExternalApplicationView) => void; +} + +function applicationSummary(application: ExternalApplicationView, t: TFunction): string { + const parts = [ + application.counts.commands > 0 + ? t('applications.counts.commands', { count: application.counts.commands }) + : null, + application.counts.tools > 0 + ? t('applications.counts.tools', { count: application.counts.tools }) + : null, + application.counts.agents > 0 + ? t('applications.counts.agents', { count: application.counts.agents }) + : null, + application.counts.mcps > 0 + ? t('applications.counts.mcps', { count: application.counts.mcps }) + : null, + ].filter((part): part is string => Boolean(part)); + if (parts.length > 0) return parts.join(' · '); + return application.status === 'checking' + ? t('applications.summary.checking') + : t('applications.summary.noContent'); +} + +/** The application-first entry point for external AI compatibility. */ +export const ExternalAppsOverview: React.FC = ({ + applications, + t, + onConnect, + onManage, +}) => ( + +
+ {applications.map((application) => { + const canConnect = application.primaryAction === 'connect'; + const canManage = application.primaryAction === 'manage' + || application.primaryAction === 'review'; + return ( +
+
+
+ + {application.displayName} + + + {t(`applications.status.${application.status}`)} + +
+
+ {application.attentionCount > 0 + ? t('applications.summary.attention', { count: application.attentionCount }) + : applicationSummary(application, t)} +
+
+ {canConnect ? ( + + ) : canManage ? ( + + ) : null} +
+ ); + })} +
+
+); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx new file mode 100644 index 0000000000..679887b859 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { ConfigPageSection } from '../common'; +import { abbreviatedLocation, sourceScopeLabel } from './presentation'; +import type { ExternalSectionCommonProps } from './types'; + +type CommandConflict = NonNullable[number]; + +export interface ExternalCommandConflictsProps + extends Omit { + conflicts: CommandConflict[]; + onChooseConflict: (conflictKey: string, candidateId: string) => void; +} + +/** + * Prompt command name collisions across ecosystems. Selection stays a user + * decision: the controller never resolves these by registration order. + */ +export const ExternalCommandConflicts: React.FC = ({ + conflicts, + t, + busyKey, + hostCapabilities, + policyCompatible, + onChooseConflict, +}) => { + if (conflicts.length === 0) return null; + + return ( + + {conflicts.map((conflict) => { + const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( + candidate.candidateId === conflict.selectedCandidateId + && candidate.availability.state !== 'available' + )); + return ( +
+
+ {t('conflicts.commandName', { name: conflict.commandName })} +
+
+ {conflict.candidates.map((candidate) => { + const selected = conflict.selectedCandidateId === candidate.candidateId; + const available = candidate.availability.state === 'available'; + return ( +
+ + + {t(selected + ? selectedChoiceUnavailable + ? 'common.selectedUnavailable' + : 'common.selected' + : !available + ? 'conflicts.restricted' + : conflict.selectedCandidateId + ? 'common.notSelected' + : 'common.availableChoice')} + +
+ {candidate.commandDescription} + {' · '} + {sourceScopeLabel(candidate.sourceScope, t)} + {' · '} + + {abbreviatedLocation(candidate.sourceLocation)} + + {!available ? ` · ${t('conflicts.restricted')}` : ''} +
+
+ ); + })} +
+
+ {conflict.selectedCandidateId + ? t(selectedChoiceUnavailable + ? 'conflicts.currentSelectionUnavailable' + : 'conflicts.currentSelection') + : t('conflicts.pending')} +
+
+ ); + })} +
+ ); +}; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx new file mode 100644 index 0000000000..55de6cf347 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { ConfigPageRow, ConfigPageSection } from '../common'; +import { + externalSourceDiagnosticKey, + type ExternalSourcePresentationGroup, +} from '../../externalSourcePresentation'; +import { SOURCE_COUNT_LABELS, sourceDiagnosticCategory, sourceScopeLabel } from './presentation'; +import type { ExternalSectionCommonProps } from './types'; + +export interface ExternalSourceSectionProps + extends Pick { + groups: ExternalSourcePresentationGroup[]; + /** Renders the per-capability toggles owned by the controller. */ + renderSourceMembers: (group: ExternalSourcePresentationGroup) => React.ReactNode; +} + +/** + * Physical configuration sources grouped by presentation id. OpenCode is + * aggregated separately, so this section renders the remaining ecosystems. + */ +export const ExternalSourceSection: React.FC = ({ + groups, + t, + renderSourceMembers, +}) => { + if (groups.length === 0) return null; + + return ( + + {groups.map((group) => { + return ( + + + + + {group.location} + + + + {group.scopes.map((scope, index) => ( + + {index > 0 ? : null} + + {sourceScopeLabel(scope, t)} + + + ))} + + + {SOURCE_COUNT_LABELS.some( + ([capability]) => group.counts[capability] > 0, + ) ? ( + + {SOURCE_COUNT_LABELS.map(([capability, label]) => { + const count = group.counts[capability]; + return count > 0 ? ( + + {t(label, { count })} + + ) : null; + })} + + ) : null} + + )} + align="center" + > + {renderSourceMembers(group)} + + {group.diagnostics.length > 0 ? ( +
+ + {t('diagnostics.sourceSummary', { + name: group.displayName, + count: group.diagnostics.length, + })} + +
    + {group.diagnostics.map((diagnostic) => ( +
  • + {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} +
    + {t('common.technicalDetails')} + {diagnostic.code} +
    +
  • + ))} +
+
+ ) : null} +
+ ); + })} +
+ ); +}; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts new file mode 100644 index 0000000000..9268b3eb22 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import type { + ExternalSourceCatalogSnapshot, + ExternalSourceRecord, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { buildExternalSourcePresentationGroups } from '../../externalSourcePresentation'; +import { buildExternalApplicationsView } from './applicationModel'; + +const OPENCODE_CAPABILITIES = [ + { capabilityId: 'command', recommendedAccess: 'auto' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'tool', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'subagent', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'mcp', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, +]; + +function policy( + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot['integrationPolicy'] { + return { + schemaMajor: 1, + status: 'compatible', + userDefaults: { enabled: true, ecosystems: {} }, + globalEffective: { enabled: true, ecosystems: {} }, + effective: { enabled: true, ecosystems: {} }, + registeredEcosystems: [ + { ecosystemId: 'opencode', displayName: 'OpenCode', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, + { ecosystemId: 'claude-code', displayName: 'Claude Code', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, + ], + ...overrides, + }; +} + +function withMode( + ecosystemId: string, + mode: 'recommended' | 'discover_only' | 'disabled' | 'custom', +): ExternalSourceCatalogSnapshot['integrationPolicy'] { + const ecosystems = { + [ecosystemId]: { ecosystemId, mode, capabilities: {} }, + }; + return policy({ + effective: { enabled: true, ecosystems }, + globalEffective: { enabled: true, ecosystems }, + }); +} + +function source( + stableKey: string, + ecosystemId: string, + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot['sources'][number] { + return { + stableKey, + presentationGroupId: `${ecosystemId}-config`, + lifecycle: 'available', + record: { + key: { providerId: `${ecosystemId}.commands`, sourceId: 'user-configuration' }, + ecosystemId, + displayName: `${ecosystemId} configuration`, + sourceKind: 'configuration', + scope: 'user_global', + location: `~/.config/${ecosystemId}/config.json`, + executionDomainId: 'local', + health: 'available', + contentVersion: 'v1', + ...overrides, + }, + }; +} + +function snapshot( + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot { + return { + hostCapabilities: { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: true, + canRevealSourceLocation: true, + }, + generation: 1, + discoveryPending: false, + sources: [], + commands: [], + tools: [], + mcpServers: [], + subagents: [], + integrationPolicy: policy(), + ...overrides, + }; +} + +function view(input: ExternalSourceCatalogSnapshot, catalogAttention = 0) { + return buildExternalApplicationsView( + input, + buildExternalSourcePresentationGroups(input), + catalogAttention, + 'workspace', + ); +} + +describe('external application model', () => { + it('lists every registered ecosystem even when nothing was discovered', () => { + const result = view(snapshot()); + + expect(result.applications.map((application) => application.ecosystemId)) + .toEqual(['opencode', 'claude-code']); + expect(result.applications[0].status).toBe('no_configuration'); + expect(result.applications[0].primaryAction).toBe('none'); + }); + + it('reports checking while discovery is still running', () => { + const result = view(snapshot({ discoveryPending: true })); + + expect(result.applications[0].status).toBe('checking'); + }); + + it('treats a recommended ecosystem with sources as connected', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'recommended'), + })); + + const opencode = result.applications[0]; + expect(opencode.status).toBe('connected'); + expect(opencode.primaryAction).toBe('manage'); + }); + + it('keeps custom ecosystems on manage so a two-state toggle cannot flatten them', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'custom'), + })); + + expect(result.applications[0].status).toBe('connected_custom'); + expect(result.applications[0].primaryAction).toBe('manage'); + }); + + it('offers connect for a discovered but discover-only ecosystem', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'discover_only'), + })); + + expect(result.applications[0].status).toBe('discovered'); + expect(result.applications[0].primaryAction).toBe('connect'); + }); + + it('attributes tool approvals to the owning ecosystem', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'recommended'), + toolApprovalRequests: [{ + approvalKey: 'approval-1', + decisionKey: 'decision-1', + targetId: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'tool-a', + }, + sourceDisplayName: 'OpenCode', + sourceLocation: '~/.config/opencode', + sourceScope: 'user_global', + toolNames: ['tool-a'], + runtimeKind: 'node', + workingDirectory: '~/.config/opencode', + capabilities: ['file_system'], + contentVersion: 'v1', + }], + })); + + const opencode = result.applications[0]; + expect(opencode.attentionCount).toBe(1); + expect(opencode.status).toBe('needs_attention'); + expect(opencode.primaryAction).toBe('review'); + expect(result.unattributedAttentionCount).toBe(0); + }); + + it('keeps catalog diagnostics and policy incompatibility out of per-application counts', () => { + const result = view( + snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: policy({ status: 'incompatible_schema' }), + }), + 2, + ); + + expect(result.applications.every((application) => application.attentionCount === 0)) + .toBe(true); + expect(result.unattributedAttentionCount).toBe(0); + expect(result.totalAttentionCount).toBe(0); + }); + + it('does not attribute a conflict that spans two ecosystems', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode'), source('claude-user', 'claude-code')], + commandConflicts: [{ + conflictKey: 'conflict-1', + commandName: 'review', + candidates: [ + { + candidateId: 'candidate-opencode', + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'OpenCode', + ecosystemId: 'opencode', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.config/opencode', + availability: { state: 'available' }, + }, + { + candidateId: 'candidate-claude', + source: { providerId: 'claude-code.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'Claude Code', + ecosystemId: 'claude-code', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.claude', + availability: { state: 'available' }, + }, + ], + }], + })); + + expect(result.applications.every((application) => application.attentionCount === 0)) + .toBe(true); + expect(result.unattributedAttentionCount).toBe(1); + }); + + it('ignores conflicts the user already resolved', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + commandConflicts: [{ + conflictKey: 'conflict-1', + commandName: 'review', + selectedCandidateId: 'candidate-opencode', + candidates: [{ + candidateId: 'candidate-opencode', + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'OpenCode', + ecosystemId: 'opencode', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.config/opencode', + availability: { state: 'available' }, + }], + }], + })); + + expect(result.totalAttentionCount).toBe(0); + }); + + it('exposes what connecting would enable so the dialog never hard-codes access levels', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'discover_only'), + commands: [{ + candidateId: 'command-1', + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'review', + }, + name: 'review', + description: 'Review', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + })); + + const plan = result.applications[0].connectPlan; + expect(plan.find((entry) => entry.capabilityId === 'command')) + .toMatchObject({ recommendedAccess: 'auto', count: 1 }); + expect(plan.find((entry) => entry.capabilityId === 'tool')) + .toMatchObject({ recommendedAccess: 'ask_before_use', count: 0 }); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts new file mode 100644 index 0000000000..c4c6f9fd21 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -0,0 +1,290 @@ +import type { + ExternalIntegrationAccess, + ExternalIntegrationMode, + ExternalSourceCatalogSnapshot, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { + ExternalSourceCapabilityCounts, + ExternalSourcePresentationGroup, +} from '../../externalSourcePresentation'; + +/** + * Application status shown on the overview. + * + * V1 has no application connection facts, so `connected` is derived from the + * effective integration mode rather than a real connection lifecycle. The + * design's "temporarily unavailable" state is intentionally absent: V1 cannot + * separate "not installed" from "probe failed", and guessing would mislead. + */ +export type ExternalApplicationStatus = + | 'needs_attention' + | 'connected' + | 'connected_custom' + | 'discovered' + | 'checking' + | 'no_configuration'; + +export type ExternalApplicationAction = 'connect' | 'manage' | 'review' | 'none'; + +export interface ExternalApplicationCapabilityPlan { + capabilityId: string; + /** Access this capability reaches once the ecosystem switches to recommended. */ + recommendedAccess: ExternalIntegrationAccess; + count: number; +} + +export interface ExternalApplicationView { + ecosystemId: string; + displayName: string; + mode: ExternalIntegrationMode; + status: ExternalApplicationStatus; + primaryAction: ExternalApplicationAction; + counts: ExternalSourceCapabilityCounts; + sourceCount: number; + locations: string[]; + /** Attention items that could be attributed to this ecosystem. */ + attentionCount: number; + /** What switching to `recommended` would enable, used by the connect dialog. */ + connectPlan: ExternalApplicationCapabilityPlan[]; +} + +export interface ExternalApplicationsView { + applications: ExternalApplicationView[]; + /** Attention items with no ecosystem identity (catalog diagnostics, policy). */ + unattributedAttentionCount: number; + totalAttentionCount: number; +} + +const CAPABILITY_COUNT_FIELD: Record = { + command: 'commands', + tool: 'tools', + subagent: 'agents', + mcp: 'mcps', +}; + +function sourcePairKey(providerId: string, sourceId: string): string { + return `${providerId}\u0000${sourceId}`; +} + +/** + * Maps every discovered source pair to its ecosystem so attention items that + * only carry a source identity can still be attributed to an application. + */ +function ecosystemBySourcePair(snapshot: ExternalSourceCatalogSnapshot): Map { + const bySource = new Map(); + for (const source of snapshot.sources) { + bySource.set( + sourcePairKey(source.record.key.providerId, source.record.key.sourceId), + source.record.ecosystemId, + ); + } + return bySource; +} + +function addAttention(counts: Map, ecosystemId: string | undefined): boolean { + if (!ecosystemId) return false; + counts.set(ecosystemId, (counts.get(ecosystemId) ?? 0) + 1); + return true; +} + +/** + * Attributes pending approvals and unresolved conflicts to ecosystems. + * + * Items that cannot be attributed — catalog-level diagnostics, policy + * incompatibility, conflict candidates without a source — are counted + * separately instead of being spread across applications. + */ +function attentionByEcosystem( + snapshot: ExternalSourceCatalogSnapshot, + _groups: ExternalSourcePresentationGroup[], + _catalogAttentionCount: number, + _policyIncompatible: boolean, +): { byEcosystem: Map; unattributed: number } { + const byEcosystem = new Map(); + const bySource = ecosystemBySourcePair(snapshot); + // Diagnostics and policy incompatibility are system status, not user + // decisions. They must not inflate the review count shown in the overview. + let unattributed = 0; + + for (const request of snapshot.toolApprovalRequests ?? []) { + const ecosystemId = bySource.get(sourcePairKey( + request.targetId.source.providerId, + request.targetId.source.sourceId, + )); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + for (const request of snapshot.mcpApprovalRequests ?? []) { + const ecosystemId = bySource.get(sourcePairKey( + request.definition.id.source.providerId, + request.definition.id.source.sourceId, + )); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + const subagentById = new Map( + (snapshot.subagents ?? []).map((agent) => [agent.candidateId, agent]), + ); + for (const candidateId of snapshot.pendingSubagentApprovals ?? []) { + const agent = subagentById.get(candidateId); + // A subagent may span several sources; the first resolvable one owns the + // item so a single approval is never counted twice. + const ecosystemId = agent?.sourceKeys + .map((key) => bySource.get(sourcePairKey(key.providerId, key.sourceId))) + .find((value): value is string => Boolean(value)); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + for (const conflict of snapshot.commandConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set(conflict.candidates.map((candidate) => candidate.ecosystemId)); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + // Cross-ecosystem collisions belong to no single application. + unattributed += 1; + } + } + + for (const conflict of snapshot.toolConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set( + conflict.candidates + .map((candidate) => (candidate.source + ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) + : undefined)) + .filter((value): value is string => Boolean(value)), + ); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + unattributed += 1; + } + } + + for (const conflict of snapshot.mcpConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set( + conflict.candidates + .map((candidate) => (candidate.source + ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) + : undefined)) + .filter((value): value is string => Boolean(value)), + ); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + unattributed += 1; + } + } + + for (const conflict of snapshot.subagentConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + // Subagent conflict candidates carry no source identity in V1. + unattributed += 1; + } + + return { byEcosystem, unattributed }; +} + +function statusFor( + mode: ExternalIntegrationMode, + sourceCount: number, + attentionCount: number, + discoveryPending: boolean, +): ExternalApplicationStatus { + if (attentionCount > 0) return 'needs_attention'; + if (sourceCount === 0) return discoveryPending ? 'checking' : 'no_configuration'; + if (mode === 'recommended') return 'connected'; + if (mode === 'custom') return 'connected_custom'; + return 'discovered'; +} + +function actionFor(status: ExternalApplicationStatus): ExternalApplicationAction { + switch (status) { + case 'needs_attention': + return 'review'; + case 'connected': + case 'connected_custom': + return 'manage'; + case 'discovered': + return 'connect'; + default: + return 'none'; + } +} + +/** + * Builds the application-level overview from a V1 snapshot. + * + * Pure derivation: no host calls, no policy decisions beyond reading the + * effective mode the host already computed. + */ +export function buildExternalApplicationsView( + snapshot: ExternalSourceCatalogSnapshot | null, + groups: ExternalSourcePresentationGroup[], + catalogAttentionCount: number, + policyScope: 'user' | 'workspace', +): ExternalApplicationsView { + if (!snapshot) { + return { applications: [], unattributedAttentionCount: 0, totalAttentionCount: 0 }; + } + + const policy = snapshot.integrationPolicy; + const policyIncompatible = policy.status !== 'compatible'; + const effective = policyScope === 'workspace' ? policy.effective : policy.globalEffective; + const { byEcosystem, unattributed } = attentionByEcosystem( + snapshot, + groups, + catalogAttentionCount, + policyIncompatible, + ); + + const applications = policy.registeredEcosystems.map((descriptor) => { + const ecosystemId = descriptor.ecosystemId; + const ecosystemGroups = groups.filter((group) => group.ecosystemId === ecosystemId); + const sources = snapshot.sources.filter( + (source) => source.record.ecosystemId === ecosystemId, + ); + const counts = ecosystemGroups.reduce((total, group) => ({ + commands: total.commands + group.counts.commands, + tools: total.tools + group.counts.tools, + agents: total.agents + group.counts.agents, + mcps: total.mcps + group.counts.mcps, + }), { commands: 0, tools: 0, agents: 0, mcps: 0 }); + + const mode = effective.ecosystems[ecosystemId]?.mode ?? 'recommended'; + const attentionCount = byEcosystem.get(ecosystemId) ?? 0; + const status = statusFor(mode, sources.length, attentionCount, snapshot.discoveryPending); + + return { + ecosystemId, + displayName: descriptor.displayName, + mode, + status, + primaryAction: actionFor(status), + counts, + sourceCount: sources.length, + locations: Array.from(new Set(sources.map((source) => source.record.location))), + attentionCount, + connectPlan: descriptor.capabilities.map((capability) => ({ + capabilityId: capability.capabilityId, + recommendedAccess: capability.recommendedAccess, + count: CAPABILITY_COUNT_FIELD[capability.capabilityId] + ? counts[CAPABILITY_COUNT_FIELD[capability.capabilityId]] + : 0, + })), + }; + }); + + const totalAttentionCount = applications.reduce( + (total, application) => total + application.attentionCount, + unattributed, + ); + + return { + applications, + unattributedAttentionCount: unattributed, + totalAttentionCount, + }; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts new file mode 100644 index 0000000000..5e68fe9e95 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -0,0 +1,23 @@ +export type { ExternalHostCapabilities, ExternalSectionCommonProps } from './types'; +export { + SOURCE_COUNT_LABELS, + abbreviatedLocation, + sourceDiagnosticCategory, + sourceScopeLabel, +} from './presentation'; +export { ExternalCommandConflicts } from './ExternalCommandConflicts'; +export type { ExternalCommandConflictsProps } from './ExternalCommandConflicts'; +export { ExternalSourceSection } from './ExternalSourceSection'; +export type { ExternalSourceSectionProps } from './ExternalSourceSection'; +export { useExternalAppAwareness } from './useExternalAppAwareness'; +export { ExternalAppsOverview } from './ExternalAppsOverview'; +export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; +export { ExternalAppDetail } from './ExternalAppDetail'; +export type { ExternalAppDetailProps } from './ExternalAppDetail'; +export { buildExternalApplicationsView } from './applicationModel'; +export type { + ExternalApplicationView, + ExternalApplicationsView, + ExternalApplicationStatus, + ExternalApplicationAction, +} from './applicationModel'; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts new file mode 100644 index 0000000000..f9a80f033c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts @@ -0,0 +1,69 @@ +import type { TFunction } from 'i18next'; +import type { + ExternalSourceCatalogSnapshot, + ExternalToolCatalogEntry, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; + +/** Trims deep paths to the trailing segments that identify the source. */ +export function abbreviatedLocation(location: string): string { + const normalized = location.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + return segments.length <= 3 ? normalized : `…/${segments.slice(-3).join('/')}`; +} + +export function matchesToolSource( + source: ExternalSourceCatalogSnapshot['sources'][number], + tool: ExternalToolCatalogEntry, +): boolean { + return source.record.key.providerId === tool.definition.id.target.source.providerId + && source.record.key.sourceId === tool.definition.id.target.source.sourceId; +} + +export function executionLocationLabel(t: TFunction, executionDomainId?: string): string { + if (executionDomainId?.startsWith('local')) return t('executionLocation.local'); + if (executionDomainId?.startsWith('remote')) return t('executionLocation.remote'); + return t('executionLocation.unknown'); +} + +export function sourceScopeLabel(scope: string, t: TFunction): string { + return scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${scope}`); +} + +/** Capability counters shown as badges on a source group. */ +export const SOURCE_COUNT_LABELS = [ + ['commands', 'sources.commandCount'], + ['tools', 'sources.toolCount'], + ['agents', 'sources.agentCount'], + ['mcps', 'sources.mcpCount'], +] as const; + +/** + * Maps a raw diagnostic code to a user-facing category. Codes stay in the + * collapsed technical details; the category is what the user reads first. + */ +export function sourceDiagnosticCategory(code: string): string { + if (code.includes('preference_read_failed')) return 'confirmationStateUnavailable'; + if (code.includes('conflict_history_write_failed')) return 'conflictHistoryUnavailable'; + if (code.includes('discovery_in_progress')) return 'checkInProgress'; + if (code.includes('timeout')) return 'checkTimedOut'; + if (code.includes('trust_required')) return 'confirmationRequired'; + if (code.includes('too_large') || code.includes('file_limit') || code.includes('bytes_limit')) { + return 'sourceTooLarge'; + } + if (code.includes('invalid') || code.includes('parse') || code.includes('definition') + || code.includes('export_missing') || code.includes('name_unsupported')) { + return 'invalidSettings'; + } + if (code.includes('unreadable') || code.includes('read_failed') + || code.includes('metadata_failed') || code.includes('directory_')) { + return 'unreadableSource'; + } + if (code.includes('projection_only') || code.includes('unsupported') + || code.includes('restricted')) { + return 'notSupported'; + } + if (code.includes('failed')) return 'checkFailed'; + return 'sourceIssue'; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/types.ts b/src/web-ui/src/infrastructure/config/components/external-sources/types.ts new file mode 100644 index 0000000000..762e35f70d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/types.ts @@ -0,0 +1,27 @@ +import type { TFunction } from 'i18next'; +import type React from 'react'; +import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; + +/** + * Host capability gates travel with the snapshot, so section components read + * them from the same authoritative object the controller validated. + */ +export type ExternalHostCapabilities = ExternalSourceCatalogSnapshot['hostCapabilities']; + +/** + * Shared contract for every external-sources section. + * + * Sections are presentation-only: they hold no state, issue no requests, and + * derive no policy. The controller owns request sequencing, mutation fencing + * and stale-response rejection, and passes results down through these props. + */ +export interface ExternalSectionCommonProps { + snapshot: ExternalSourceCatalogSnapshot; + t: TFunction; + /** Mutation key currently in flight, used to drive per-control loading state. */ + busyKey: string | null; + hostCapabilities: ExternalHostCapabilities; + policyCompatible: boolean; + /** Renders a reveal-in-explorer link, or a disabled hint when unsupported. */ + renderPathLink: (location: string, sourceKey?: string) => React.ReactNode; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts new file mode 100644 index 0000000000..a21c5a9e91 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react'; +import { externalSourcesAPI } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { useOptionalCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; +import { createLogger } from '@/shared/utils/logger'; + +const logger = createLogger('ExternalAppAwareness'); + +/** Marks the external sources tab when the host found an application the user + * has never been told about, and clears it once they open the tab. + * + * The lookup is lazy on purpose: it only runs while the settings scene is + * mounted, so a user who never opens settings pays nothing. Failures stay + * silent because a missing hint is far less harmful than an error toast for + * something the user did not ask for. + */ +export function useExternalAppAwareness(): void { + const { workspacePath } = useOptionalCurrentWorkspace(); + const activeTab = useSettingsStore((state) => state.activeTab); + const markTabUnseen = useSettingsStore((state) => state.markTabUnseen); + const acknowledgedScopeRef = useRef(null); + + useEffect(() => { + let cancelled = false; + void externalSourcesAPI + .getEcosystemAwareness(workspacePath) + .then((unacknowledged) => { + if (cancelled) return; + markTabUnseen('external-sources', unacknowledged.length > 0); + }) + .catch((error) => { + logger.debug('Could not read external application awareness', { error }); + }); + return () => { + cancelled = true; + }; + }, [markTabUnseen, workspacePath]); + + useEffect(() => { + if (activeTab !== 'external-sources' || acknowledgedScopeRef.current === workspacePath) return; + acknowledgedScopeRef.current = workspacePath; + // Clear the dot immediately: the user is looking at the list right now, so + // waiting for the host round-trip would leave a stale marker on screen. + markTabUnseen('external-sources', false); + void externalSourcesAPI + .getEcosystemAwareness(workspacePath) + .then((unacknowledged) => (unacknowledged.length > 0 + ? externalSourcesAPI.acknowledgeEcosystems(workspacePath, unacknowledged) + : undefined)) + .catch((error) => { + logger.debug('Could not record external application awareness', { error }); + }); + }, [activeTab, markTabUnseen, workspacePath]); +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 27c7017b7d..97fc9ed608 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -802,6 +802,10 @@ "nativeCommandReconfirmationRequired": "The external command you previously chose is no longer available. Choose the BitFun command from the slash menu to run it.", "selectHint": "↑↓ Select · Enter Confirm · Esc Cancel", "current": "Current", + "commandStatus": { + "restricted": "Restricted", + "chooseSource": "Choose source" + }, "computerUseDisabled": "Computer Use is disabled in Settings → Session permissions", "professionalMode": "Expert Mode", "designMode": "Design Mode", diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 80009a077d..92c344519f 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "No matching settings", "searchClear": "Clear search", "beta": "Beta", + "unseenItems": "Not seen yet", "searchAliases": { "archivedSessions": [], "keyboard": [], diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index fab5c93320..2c57837734 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "This execution Host uses the legacy external-source protocol. Safe Mode requires a Host upgrade.", "loading": "Checking external sources…", "checkingNonBlocking": "Checking for updates…", + "applications": { + "title": "Discovered applications", + "description": "BitFun continuously reads connected application settings and checks changes before they become available.", + "status": { + "needs_attention": "Needs attention", + "connected": "Connected", + "connected_custom": "Connected · Custom", + "discovered": "Configuration found", + "checking": "Checking", + "no_configuration": "No configuration found" + }, + "summary": { + "attention": "{{count}} item needs review", + "attention_other": "{{count}} items need review", + "checking": "Looking for configuration", + "noContent": "Nothing available yet" + }, + "counts": { + "commands_one": "{{count}} command", + "commands_other": "{{count}} commands", + "tools_one": "{{count}} tool", + "tools_other": "{{count}} tools", + "agents_one": "{{count}} agent", + "agents_other": "{{count}} agents", + "mcps_one": "{{count}} MCP server", + "mcps_other": "{{count}} MCP servers" + }, + "actions": { + "connect": "Connect", + "manage": "Manage", + "review": "Review" + }, + "connectTitle": "Connect {{name}}", + "connectMessage": "{{commands}} commands will be available automatically. {{tools}} tools, {{agents}} agents, and {{mcps}} MCP servers will still require confirmation before they can run.", + "connectionComplete": "The application is connected. Review any capabilities that still need confirmation.", + "advanced": { + "title": "Advanced settings", + "description": "Sources, capability access, diagnostics, and conflicts" + }, + "detail": { + "back": "Back to applications", + "reviewTitle_one": "{{count}} item needs review", + "reviewTitle_other": "{{count}} items need review", + "reviewDescription": "Executable capabilities do not run until they are confirmed.", + "sourceSummary_one": "{{count}} configuration source", + "sourceSummary_other": "{{count}} configuration sources", + "usingTitle": "Available content", + "usingDescription": "Low-risk content can be available automatically; executable content remains controlled.", + "foundCount_one": "{{count}} found", + "foundCount_other": "{{count}} found", + "autoAvailable": "Available automatically", + "managed": "Managed", + "capabilities": { + "commands": "Commands", + "tools": "Tools", + "agents": "Agents", + "mcps": "MCP servers" + } + } + }, "hooks": { "title": "Hooks", "description": "View Hook configuration from OpenCode, Claude Code, and Codex. BitFun only reads config files without running any code.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 9317e0c39a..9d1f686bfb 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -796,6 +796,10 @@ "nativeCommandReconfirmationRequired": "之前选择的外部命令已不可用。请从斜杠菜单中选择 BitFun 命令后再执行。", "selectHint": "↑↓ 选择 · Enter 确认 · Esc 取消", "current": "当前", + "commandStatus": { + "restricted": "受限", + "chooseSource": "选择来源" + }, "computerUseDisabled": "已在「设置 → 会话权限」中禁用 Computer Use", "professionalMode": "专业模式", "designMode": "设计模式", diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index c457d350f7..fbf3396cc6 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "没有匹配的配置", "searchClear": "清除搜索", "beta": "Beta", + "unseenItems": "尚未查看", "searchAliases": { "archivedSessions": [ "归档", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 8116a23cae..bb7bcc6771 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "当前执行 Host 使用旧版外部来源协议;升级 Host 后才能使用安全模式。", "loading": "正在检查外部来源…", "checkingNonBlocking": "正在检查更新…", + "applications": { + "title": "已发现的应用", + "description": "连接后 BitFun 会持续读取来源配置,并在内容可用前检查变更。", + "status": { + "needs_attention": "需要处理", + "connected": "已连接", + "connected_custom": "已连接 · 自定义", + "discovered": "发现可用配置", + "checking": "正在检查", + "no_configuration": "未发现配置" + }, + "summary": { + "attention": "{{count}} 项等待确认", + "attention_other": "{{count}} 项等待确认", + "checking": "正在查找配置", + "noContent": "暂时没有可用内容" + }, + "counts": { + "commands_one": "{{count}} 个命令", + "commands_other": "{{count}} 个命令", + "tools_one": "{{count}} 个工具", + "tools_other": "{{count}} 个工具", + "agents_one": "{{count}} 个 Agent", + "agents_other": "{{count}} 个 Agent", + "mcps_one": "{{count}} 个 MCP 服务器", + "mcps_other": "{{count}} 个 MCP 服务器" + }, + "actions": { + "connect": "连接", + "manage": "管理", + "review": "检查" + }, + "connectTitle": "连接 {{name}}", + "connectMessage": "{{commands}} 个命令将自动可用;{{tools}} 个工具、{{agents}} 个 Agent 和 {{mcps}} 个 MCP 服务器在运行前仍需要确认。", + "connectionComplete": "应用已连接。仍需确认的能力会保留在检查列表中。", + "advanced": { + "title": "高级设置", + "description": "配置来源、能力访问、诊断与冲突" + }, + "detail": { + "back": "返回应用列表", + "reviewTitle_one": "{{count}} 项等待确认", + "reviewTitle_other": "{{count}} 项等待确认", + "reviewDescription": "可执行能力在确认前不会运行。", + "sourceSummary_one": "{{count}} 个配置来源", + "sourceSummary_other": "{{count}} 个配置来源", + "usingTitle": "可用内容", + "usingDescription": "低风险内容可以自动可用;可执行内容仍受控。", + "foundCount_one": "发现 {{count}} 项", + "foundCount_other": "发现 {{count}} 项", + "autoAvailable": "自动可用", + "managed": "管理", + "capabilities": { + "commands": "命令", + "tools": "工具", + "agents": "Agent", + "mcps": "MCP 服务器" + } + } + }, "hooks": { "title": "Hooks", "description": "查看 OpenCode、Claude Code 和 Codex 的 Hook 配置。BitFun 只读取配置文件,不运行任何代码。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d3b210c9e3..76a016de51 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -796,6 +796,10 @@ "nativeCommandReconfirmationRequired": "先前選擇的外部命令已無法使用。請從斜線選單中選擇 BitFun 命令後再執行。", "selectHint": "↑↓ 選擇 · Enter 確認 · Esc 取消", "current": "目前", + "commandStatus": { + "restricted": "受限", + "chooseSource": "選擇來源" + }, "computerUseDisabled": "已在「設定 → 工作階段權限」中停用 Computer Use", "professionalMode": "專業模式", "designMode": "設計模式", diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 272c1c7c50..a3e49b264e 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "沒有匹配的設定", "searchClear": "清除搜尋", "beta": "Beta", + "unseenItems": "尚未檢視", "searchAliases": { "archivedSessions": [ "歸檔", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 7f865abf49..6bbcff8911 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "目前執行 Host 使用舊版外部來源協定;升級 Host 後才能使用安全模式。", "loading": "正在檢查外部來源…", "checkingNonBlocking": "正在檢查更新…", + "applications": { + "title": "已發現的應用", + "description": "連線後 BitFun 會持續讀取來源設定,並在內容可用前檢查變更。", + "status": { + "needs_attention": "需要處理", + "connected": "已連線", + "connected_custom": "已連線 · 自訂", + "discovered": "發現可用設定", + "checking": "正在檢查", + "no_configuration": "未發現設定" + }, + "summary": { + "attention": "{{count}} 項等待確認", + "attention_other": "{{count}} 項等待確認", + "checking": "正在尋找設定", + "noContent": "暫時沒有可用內容" + }, + "counts": { + "commands_one": "{{count}} 個命令", + "commands_other": "{{count}} 個命令", + "tools_one": "{{count}} 個工具", + "tools_other": "{{count}} 個工具", + "agents_one": "{{count}} 個 Agent", + "agents_other": "{{count}} 個 Agent", + "mcps_one": "{{count}} 個 MCP 伺服器", + "mcps_other": "{{count}} 個 MCP 伺服器" + }, + "actions": { + "connect": "連線", + "manage": "管理", + "review": "檢查" + }, + "connectTitle": "連線 {{name}}", + "connectMessage": "{{commands}} 個命令將自動可用;{{tools}} 個工具、{{agents}} 個 Agent 和 {{mcps}} 個 MCP 伺服器在執行前仍需要確認。", + "connectionComplete": "應用已連線。仍需確認的能力會保留在檢查清單中。", + "advanced": { + "title": "進階設定", + "description": "設定來源、能力存取、診斷與衝突" + }, + "detail": { + "back": "返回應用清單", + "reviewTitle_one": "{{count}} 項等待確認", + "reviewTitle_other": "{{count}} 項等待確認", + "reviewDescription": "可執行能力在確認前不會執行。", + "sourceSummary_one": "{{count}} 個設定來源", + "sourceSummary_other": "{{count}} 個設定來源", + "usingTitle": "可用內容", + "usingDescription": "低風險內容可以自動可用;可執行內容仍受控。", + "foundCount_one": "發現 {{count}} 項", + "foundCount_other": "發現 {{count}} 項", + "autoAvailable": "自動可用", + "managed": "管理", + "capabilities": { + "commands": "命令", + "tools": "工具", + "agents": "Agent", + "mcps": "MCP 伺服器" + } + } + }, "hooks": { "title": "Hooks", "description": "檢視 OpenCode、Claude Code 與 Codex 的 Hook 設定。BitFun 只讀取設定檔,不執行任何程式碼。", From aebded0e348fef8f8147520a73e9ea3c5f1c91f0 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 17:37:40 +0800 Subject: [PATCH 047/206] fix(external-sources): harden application connection UI --- .../components/ExternalSourcesConfig.scss | 3 +- .../components/ExternalSourcesConfig.tsx | 14 ++- .../external-sources/applicationModel.test.ts | 29 +++-- .../external-sources/applicationModel.ts | 31 ++++-- .../components/external-sources/index.ts | 2 +- .../useExternalAppAwareness.test.tsx | 100 ++++++++++++++++++ .../useExternalAppAwareness.ts | 22 ++-- .../en-US/settings/external-sources.json | 6 +- .../zh-CN/settings/external-sources.json | 6 +- .../zh-TW/settings/external-sources.json | 6 +- 10 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 81c07748ee..52fbca4e33 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -123,8 +123,7 @@ strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } small { margin-top: 3px; } } - - + &__app-list { display: grid; overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 68e6ed323e..16b9278c51 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -63,6 +63,7 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, + buildExternalConnectionMessage, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -631,8 +632,8 @@ const ExternalSourcesConfig: React.FC = () => { [snapshot, sourceGroups], ); const applicationsView = useMemo( - () => buildExternalApplicationsView(snapshot, sourceGroups, catalogDiagnostics.length, policyScope), - [catalogDiagnostics.length, policyScope, snapshot, sourceGroups], + () => buildExternalApplicationsView(snapshot, sourceGroups, policyScope), + [policyScope, snapshot, sourceGroups], ); const selectedApplication = applicationsView.applications.find( (application) => application.ecosystemId === selectedApplicationId, @@ -3188,12 +3189,9 @@ const ExternalSourcesConfig: React.FC = () => { onClose={() => setConnectingApplication(null)} onConfirm={() => void connectApplication()} title={connectingApplication ? t('applications.connectTitle', { name: connectingApplication.displayName }) : ''} - message={connectingApplication ? t('applications.connectMessage', { - commands: connectingApplication.counts.commands, - tools: connectingApplication.counts.tools, - agents: connectingApplication.counts.agents, - mcps: connectingApplication.counts.mcps, - }) : ''} + message={connectingApplication + ? buildExternalConnectionMessage(connectingApplication.connectPlan, t, policyScope) + : ''} type="info" confirmText={t('applications.actions.connect')} /> diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts index 9268b3eb22..9e0c2b528d 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts @@ -4,7 +4,7 @@ import type { ExternalSourceRecord, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { buildExternalSourcePresentationGroups } from '../../externalSourcePresentation'; -import { buildExternalApplicationsView } from './applicationModel'; +import { buildExternalApplicationsView, buildExternalConnectionMessage } from './applicationModel'; const OPENCODE_CAPABILITIES = [ { capabilityId: 'command', recommendedAccess: 'auto' as const, safetyCeiling: 'auto' as const }, @@ -92,11 +92,10 @@ function snapshot( }; } -function view(input: ExternalSourceCatalogSnapshot, catalogAttention = 0) { +function view(input: ExternalSourceCatalogSnapshot) { return buildExternalApplicationsView( input, buildExternalSourcePresentationGroups(input), - catalogAttention, 'workspace', ); } @@ -178,13 +177,10 @@ describe('external application model', () => { }); it('keeps catalog diagnostics and policy incompatibility out of per-application counts', () => { - const result = view( - snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: policy({ status: 'incompatible_schema' }), - }), - 2, - ); + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: policy({ status: 'incompatible_schema' }), + })); expect(result.applications.every((application) => application.attentionCount === 0)) .toBe(true); @@ -279,4 +275,17 @@ describe('external application model', () => { expect(plan.find((entry) => entry.capabilityId === 'tool')) .toMatchObject({ recommendedAccess: 'ask_before_use', count: 0 }); }); + + it('builds connection copy from the capability plan instead of fixed capability names', () => { + const result = view(snapshot({ + sources: [source('claude-user', 'claude-code')], + integrationPolicy: withMode('claude-code', 'discover_only'), + })); + + expect(buildExternalConnectionMessage(result.applications[1].connectPlan, (key, params) => + `${key}:${JSON.stringify(params)}`, + )).toBe( + 'applications.connectSummary:{"automaticCount":0,"managedCount":0,"scope":"applications.connectScope.workspace:undefined"}', + ); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts index c4c6f9fd21..e53422c1f7 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -3,6 +3,7 @@ import type { ExternalIntegrationMode, ExternalSourceCatalogSnapshot, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { TFunction } from 'i18next'; import type { ExternalSourceCapabilityCounts, ExternalSourcePresentationGroup, @@ -62,6 +63,24 @@ const CAPABILITY_COUNT_FIELD: Record entry.recommendedAccess === 'auto') + .reduce((total, entry) => total + entry.count, 0); + const managedCount = plan + .filter((entry) => entry.recommendedAccess !== 'auto') + .reduce((total, entry) => total + entry.count, 0); + return t('applications.connectSummary', { + automaticCount, + managedCount, + scope: t(`applications.connectScope.${scope}`), + }); +} + function sourcePairKey(providerId: string, sourceId: string): string { return `${providerId}\u0000${sourceId}`; } @@ -96,9 +115,6 @@ function addAttention(counts: Map, ecosystemId: string | undefin */ function attentionByEcosystem( snapshot: ExternalSourceCatalogSnapshot, - _groups: ExternalSourcePresentationGroup[], - _catalogAttentionCount: number, - _policyIncompatible: boolean, ): { byEcosystem: Map; unattributed: number } { const byEcosystem = new Map(); const bySource = ecosystemBySourcePair(snapshot); @@ -223,7 +239,6 @@ function actionFor(status: ExternalApplicationStatus): ExternalApplicationAction export function buildExternalApplicationsView( snapshot: ExternalSourceCatalogSnapshot | null, groups: ExternalSourcePresentationGroup[], - catalogAttentionCount: number, policyScope: 'user' | 'workspace', ): ExternalApplicationsView { if (!snapshot) { @@ -231,14 +246,8 @@ export function buildExternalApplicationsView( } const policy = snapshot.integrationPolicy; - const policyIncompatible = policy.status !== 'compatible'; const effective = policyScope === 'workspace' ? policy.effective : policy.globalEffective; - const { byEcosystem, unattributed } = attentionByEcosystem( - snapshot, - groups, - catalogAttentionCount, - policyIncompatible, - ); + const { byEcosystem, unattributed } = attentionByEcosystem(snapshot); const applications = policy.registeredEcosystems.map((descriptor) => { const ecosystemId = descriptor.ecosystemId; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts index 5e68fe9e95..ec452b5fbd 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -14,7 +14,7 @@ export { ExternalAppsOverview } from './ExternalAppsOverview'; export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; export { ExternalAppDetail } from './ExternalAppDetail'; export type { ExternalAppDetailProps } from './ExternalAppDetail'; -export { buildExternalApplicationsView } from './applicationModel'; +export { buildExternalApplicationsView, buildExternalConnectionMessage } from './applicationModel'; export type { ExternalApplicationView, ExternalApplicationsView, diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx new file mode 100644 index 0000000000..923b5356e2 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useExternalAppAwareness } from './useExternalAppAwareness'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const getAwarenessMock = vi.hoisted(() => vi.fn()); +const acknowledgeMock = vi.hoisted(() => vi.fn()); +const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project', kind: 'normal' })); +const settingsState = vi.hoisted(() => ({ activeTab: 'general', markTabUnseen: vi.fn() })); + +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ + externalSourcesAPI: { + getEcosystemAwareness: getAwarenessMock, + acknowledgeEcosystems: acknowledgeMock, + }, +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useOptionalCurrentWorkspace: () => ({ + workspace: { workspaceKind: workspaceState.kind }, + workspacePath: workspaceState.path, + }), +})); +vi.mock('@/app/scenes/settings/settingsStore', () => ({ + useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState), +})); +vi.mock('@/shared/types', () => ({ + isRemoteWorkspace: (workspace: { workspaceKind?: string } | null) => workspace?.workspaceKind === 'remote', +})); +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ debug: vi.fn() }), +})); + +function Harness() { + useExternalAppAwareness(); + return null; +} + +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +describe('useExternalAppAwareness', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + root = createRoot(container); + workspaceState.path = 'D:/workspace/project'; + workspaceState.kind = 'normal'; + settingsState.activeTab = 'general'; + settingsState.markTabUnseen.mockReset(); + getAwarenessMock.mockReset().mockResolvedValue(['opencode']); + acknowledgeMock.mockReset().mockResolvedValue(undefined); + }); + + it('does not call local-only awareness commands for remote workspaces', async () => { + workspaceState.kind = 'remote'; + await act(async () => { root.render(); }); + await flush(); + + expect(getAwarenessMock).not.toHaveBeenCalled(); + }); + + it('does not restore a stale dot after acknowledgement wins the initial-read race', async () => { + let resolveInitial: ((ids: string[]) => void) | undefined; + getAwarenessMock + .mockImplementationOnce(() => new Promise((resolve) => { resolveInitial = resolve; })) + .mockResolvedValueOnce(['opencode']); + settingsState.activeTab = 'external-sources'; + + await act(async () => { root.render(); }); + await flush(); + await flush(); + await act(async () => { resolveInitial?.(['opencode']); }); + + expect(settingsState.markTabUnseen).not.toHaveBeenLastCalledWith('external-sources', true); + }); + + it('allows acknowledgement to retry after a failed persistence attempt', async () => { + settingsState.activeTab = 'external-sources'; + acknowledgeMock.mockRejectedValueOnce(new Error('write failed')).mockResolvedValueOnce(undefined); + await act(async () => { root.render(); }); + await flush(); + await flush(); + + settingsState.activeTab = 'general'; + await act(async () => { root.render(); }); + settingsState.activeTab = 'external-sources'; + await act(async () => { root.render(); }); + await flush(); + await flush(); + + expect(acknowledgeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts index a21c5a9e91..7663bcb454 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { externalSourcesAPI } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { useOptionalCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { isRemoteWorkspace } from '@/shared/types'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import { createLogger } from '@/shared/utils/logger'; @@ -15,17 +16,18 @@ const logger = createLogger('ExternalAppAwareness'); * something the user did not ask for. */ export function useExternalAppAwareness(): void { - const { workspacePath } = useOptionalCurrentWorkspace(); + const { workspace, workspacePath } = useOptionalCurrentWorkspace(); const activeTab = useSettingsStore((state) => state.activeTab); const markTabUnseen = useSettingsStore((state) => state.markTabUnseen); const acknowledgedScopeRef = useRef(null); useEffect(() => { + if (isRemoteWorkspace(workspace)) return; let cancelled = false; void externalSourcesAPI .getEcosystemAwareness(workspacePath) .then((unacknowledged) => { - if (cancelled) return; + if (cancelled || acknowledgedScopeRef.current === workspacePath) return; markTabUnseen('external-sources', unacknowledged.length > 0); }) .catch((error) => { @@ -34,21 +36,25 @@ export function useExternalAppAwareness(): void { return () => { cancelled = true; }; - }, [markTabUnseen, workspacePath]); + }, [markTabUnseen, workspace, workspacePath]); useEffect(() => { - if (activeTab !== 'external-sources' || acknowledgedScopeRef.current === workspacePath) return; - acknowledgedScopeRef.current = workspacePath; - // Clear the dot immediately: the user is looking at the list right now, so - // waiting for the host round-trip would leave a stale marker on screen. + if (isRemoteWorkspace(workspace) + || activeTab !== 'external-sources' + || acknowledgedScopeRef.current === workspacePath) return; + // Clear the dot immediately while allowing a failed host write to retry. markTabUnseen('external-sources', false); void externalSourcesAPI .getEcosystemAwareness(workspacePath) .then((unacknowledged) => (unacknowledged.length > 0 ? externalSourcesAPI.acknowledgeEcosystems(workspacePath, unacknowledged) : undefined)) + .then(() => { + acknowledgedScopeRef.current = workspacePath; + }) .catch((error) => { + markTabUnseen('external-sources', true); logger.debug('Could not record external application awareness', { error }); }); - }, [activeTab, markTabUnseen, workspacePath]); + }, [activeTab, markTabUnseen, workspace, workspacePath]); } diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 2c57837734..2266491ab1 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "Review" }, "connectTitle": "Connect {{name}}", - "connectMessage": "{{commands}} commands will be available automatically. {{tools}} tools, {{agents}} agents, and {{mcps}} MCP servers will still require confirmation before they can run.", + "connectSummary": "{{automaticCount}} low-risk items will be available automatically; {{managedCount}} executable items remain managed. Scope: {{scope}}.", + "connectScope": { + "workspace": "this workspace", + "user": "all workspaces in this execution domain" + }, "connectionComplete": "The application is connected. Review any capabilities that still need confirmation.", "advanced": { "title": "Advanced settings", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index bb7bcc6771..b335a66074 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "检查" }, "connectTitle": "连接 {{name}}", - "connectMessage": "{{commands}} 个命令将自动可用;{{tools}} 个工具、{{agents}} 个 Agent 和 {{mcps}} 个 MCP 服务器在运行前仍需要确认。", + "connectSummary": "{{automaticCount}} 项低风险内容将自动可用;{{managedCount}} 项可执行内容仍受管理。作用范围:{{scope}}。", + "connectScope": { + "workspace": "仅当前工作区", + "user": "当前执行域中的所有工作区" + }, "connectionComplete": "应用已连接。仍需确认的能力会保留在检查列表中。", "advanced": { "title": "高级设置", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 6bbcff8911..b9bdeb89c9 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "檢查" }, "connectTitle": "連線 {{name}}", - "connectMessage": "{{commands}} 個命令將自動可用;{{tools}} 個工具、{{agents}} 個 Agent 和 {{mcps}} 個 MCP 伺服器在執行前仍需要確認。", + "connectSummary": "{{automaticCount}} 項低風險內容將自動可用;{{managedCount}} 項可執行內容仍受管理。作用範圍:{{scope}}。", + "connectScope": { + "workspace": "僅目前工作區", + "user": "目前執行域中的所有工作區" + }, "connectionComplete": "應用已連線。仍需確認的能力會保留在檢查清單中。", "advanced": { "title": "進階設定", From 3e6f79b662291c3ca67508b9e13dba96da78b010 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 18:44:05 +0800 Subject: [PATCH 048/206] fix(external-sources): register application appearance parts --- .../external-sources/ExternalAppDetail.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx index e805f980d8..7b937d11ec 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx @@ -30,7 +30,11 @@ export const ExternalAppDetail: React.FC = ({ onBack, onOpenAdvanced, }) => ( -
+
@@ -53,6 +57,8 @@ export const ExternalAppDetail: React.FC = ({ + ), + IconButton: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), + Select: (props: SelectSpyProps) => { + const label = props['aria-label'] ?? ''; + selectProps[label] = props; + return ( + + ); + }, + Switch: () => , + Input: (props: React.InputHTMLAttributes) => , + NumberInput: () => , + Textarea: () =>

5y6h!;PI6_nxb7Lj^4pI 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