diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 441eb47..6abed8b 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -193,9 +193,9 @@ pub async fn dashboard_stats( .and_utc(); let today_row: DashboardTodayRow = sqlx::query_as( "SELECT - (SELECT COUNT(*) FROM relay_tasks WHERE created_at >= $1 AND created_at < $2) as tasks_today, - COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0)::bigint as tokens_input, - COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0)::bigint as tokens_output" + (SELECT COUNT(*) FROM relay_tasks WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2) as tasks_today, + COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2), 0)::bigint as tokens_input, + COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE created_at::timestamptz >= $1 AND created_at::timestamptz < $2), 0)::bigint as tokens_output" ).bind(&today_start).bind(&tomorrow_start).fetch_one(&state.db).await?; Ok(Json(serde_json::json!({ diff --git a/crates/zclaw-saas/src/telemetry/service.rs b/crates/zclaw-saas/src/telemetry/service.rs index d056a75..30f31e7 100644 --- a/crates/zclaw-saas/src/telemetry/service.rs +++ b/crates/zclaw-saas/src/telemetry/service.rs @@ -97,13 +97,13 @@ pub async fn get_model_stats( param_idx += 1; if let Some(ref from) = query.from { - where_clauses.push(format!("reported_at >= ${}", param_idx)); + where_clauses.push(format!("reported_at::timestamptz >= ${}", param_idx)); params.push(from.clone()); param_idx += 1; } if let Some(ref to) = query.to { - where_clauses.push(format!("reported_at <= ${}", param_idx)); + where_clauses.push(format!("reported_at::timestamptz <= ${}", param_idx)); params.push(to.clone()); param_idx += 1; } @@ -247,7 +247,7 @@ pub async fn get_daily_stats( COUNT(DISTINCT device_id)::bigint as unique_devices FROM telemetry_reports WHERE account_id = $1 - AND reported_at >= $2 + AND reported_at::timestamptz >= $2 GROUP BY reported_at::date ORDER BY day DESC"; diff --git a/crates/zclaw-saas/src/workers/aggregate_usage.rs b/crates/zclaw-saas/src/workers/aggregate_usage.rs index c8ba1d1..8b5abab 100644 --- a/crates/zclaw-saas/src/workers/aggregate_usage.rs +++ b/crates/zclaw-saas/src/workers/aggregate_usage.rs @@ -60,7 +60,7 @@ async fn aggregate_single_account(db: &PgPool, account_id: &str) -> SaasResult<( COALESCE(SUM(output_tokens), 0)::bigint, \ COUNT(*) \ FROM usage_records \ - WHERE account_id = $1 AND created_at >= $2 AND status = 'success'" + WHERE account_id = $1 AND created_at::timestamptz >= $2 AND status = 'success'" ) .bind(account_id) .bind(period_start) diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index a0cfc33..8002256 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -10,7 +10,7 @@ import { useConfigStore } from '../store/configStore'; import { useSaaSStore } from '../store/saasStore'; import { type UnlistenFn } from '@tauri-apps/api/event'; import { safeListenEvent } from '../lib/safe-tauri'; -import { Paperclip, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search, ClipboardList } from 'lucide-react'; +import { Paperclip, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search, ClipboardList, Square } from 'lucide-react'; import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui'; import { ResizableChatLayout } from './ai/ResizableChatLayout'; import { ArtifactPanel } from './ai/ArtifactPanel'; @@ -56,6 +56,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD sendMessage: sendToGateway, initStreamListener, chatMode, setChatMode, suggestions, totalInputTokens, totalOutputTokens, + cancelStream, } = useChatStore(); const currentAgent = useConversationStore((s) => s.currentAgent); const currentModel = useConversationStore((s) => s.currentModel); @@ -571,16 +572,28 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD disabled={isStreaming} /> )} - + {isStreaming ? ( + + ) : ( + + )} diff --git a/desktop/src/lib/saas-relay-client.ts b/desktop/src/lib/saas-relay-client.ts index 96b1008..e56b551 100644 --- a/desktop/src/lib/saas-relay-client.ts +++ b/desktop/src/lib/saas-relay-client.ts @@ -16,6 +16,57 @@ import { createLogger } from './logger'; const log = createLogger('SaaSRelayGateway'); +// --------------------------------------------------------------------------- +// Frontend DataMasking — mirrors Rust DataMasking middleware for SaaS Relay +// --------------------------------------------------------------------------- + +const MASK_PATTERNS: RegExp[] = [ + /\b\d{17}[\dXx]\b/g, // ID card + /1[3-9]\d-?\d{4}-?\d{4}/g, // Phone + /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, // Email + /[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元/g, // Money + /[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)/g, // Company +]; + +let maskCounter = 0; +const entityMap = new Map(); + +/** Mask sensitive entities in text before sending to SaaS relay. */ +function maskSensitiveData(text: string): string { + const entities: { text: string; token: string }[] = []; + + for (const pattern of MASK_PATTERNS) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const entity = match[0]; + if (!entityMap.has(entity)) { + maskCounter++; + entityMap.set(entity, `__ENTITY_${maskCounter}__`); + } + entities.push({ text: entity, token: entityMap.get(entity)! }); + } + } + + // Sort by length descending to replace longest entities first + entities.sort((a, b) => b.text.length - a.text.length); + + let result = text; + for (const { text: entity, token } of entities) { + result = result.split(entity).join(token); + } + return result; +} + +/** Restore masked tokens in AI response back to original entities. */ +function unmaskSensitiveData(text: string): string { + let result = text; + for (const [entity, token] of entityMap) { + result = result.split(token).join(entity); + } + return result; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -122,10 +173,12 @@ export function createSaaSRelayGatewayClient( try { // Build messages array: use history if available, fallback to current message only + // Apply DataMasking to protect sensitive data before sending to relay const history = opts?.history || []; + const maskedMessage = maskSensitiveData(message); const messages = history.length > 0 - ? [...history, { role: 'user' as const, content: message }] - : [{ role: 'user' as const, content: message }]; + ? [...history, { role: 'user' as const, content: maskedMessage }] + : [{ role: 'user' as const, content: maskedMessage }]; const body: Record = { model: getModel() || 'glm-4-flash-250414', @@ -205,9 +258,9 @@ export function createSaaSRelayGatewayClient( callbacks.onThinkingDelta?.(delta.reasoning_content); } - // Handle regular content + // Handle regular content — unmask tokens so user sees original entities if (delta?.content) { - callbacks.onDelta(delta.content); + callbacks.onDelta(unmaskSensitiveData(delta.content)); } // Check for completion