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