fix: BUG-009/010/011 — DataMasking, cancel button, SQL casts
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
BUG-009 (P1): Add frontend DataMasking in saas-relay-client.ts - Masks ID cards, phones, emails, money, company names before relay - Unmasks tokens in AI response so user sees original data - Mirrors Rust DataMasking middleware patterns BUG-010 (P3): Send button transforms to Stop during streaming - Shows square icon when isStreaming, calls cancelStream() - Normal arrow icon when idle, calls handleSend() BUG-011 (P2): Add ::timestamptz casts for old TEXT timestamp columns - account/handlers.rs: dashboard stats query - telemetry/service.rs: reported_at comparisons - workers/aggregate_usage.rs: usage aggregation query
This commit is contained in:
@@ -193,9 +193,9 @@ pub async fn dashboard_stats(
|
|||||||
.and_utc();
|
.and_utc();
|
||||||
let today_row: DashboardTodayRow = sqlx::query_as(
|
let today_row: DashboardTodayRow = sqlx::query_as(
|
||||||
"SELECT
|
"SELECT
|
||||||
(SELECT COUNT(*) FROM relay_tasks WHERE created_at >= $1 AND created_at < $2) as tasks_today,
|
(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 >= $1 AND created_at < $2), 0)::bigint as tokens_input,
|
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 >= $1 AND created_at < $2), 0)::bigint as tokens_output"
|
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?;
|
).bind(&today_start).bind(&tomorrow_start).fetch_one(&state.db).await?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ pub async fn get_model_stats(
|
|||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
|
|
||||||
if let Some(ref from) = query.from {
|
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());
|
params.push(from.clone());
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref to) = query.to {
|
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());
|
params.push(to.clone());
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ pub async fn get_daily_stats(
|
|||||||
COUNT(DISTINCT device_id)::bigint as unique_devices
|
COUNT(DISTINCT device_id)::bigint as unique_devices
|
||||||
FROM telemetry_reports
|
FROM telemetry_reports
|
||||||
WHERE account_id = $1
|
WHERE account_id = $1
|
||||||
AND reported_at >= $2
|
AND reported_at::timestamptz >= $2
|
||||||
GROUP BY reported_at::date
|
GROUP BY reported_at::date
|
||||||
ORDER BY day DESC";
|
ORDER BY day DESC";
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async fn aggregate_single_account(db: &PgPool, account_id: &str) -> SaasResult<(
|
|||||||
COALESCE(SUM(output_tokens), 0)::bigint, \
|
COALESCE(SUM(output_tokens), 0)::bigint, \
|
||||||
COUNT(*) \
|
COUNT(*) \
|
||||||
FROM usage_records \
|
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(account_id)
|
||||||
.bind(period_start)
|
.bind(period_start)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useConfigStore } from '../store/configStore';
|
|||||||
import { useSaaSStore } from '../store/saasStore';
|
import { useSaaSStore } from '../store/saasStore';
|
||||||
import { type UnlistenFn } from '@tauri-apps/api/event';
|
import { type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { safeListenEvent } from '../lib/safe-tauri';
|
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 { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
|
||||||
import { ResizableChatLayout } from './ai/ResizableChatLayout';
|
import { ResizableChatLayout } from './ai/ResizableChatLayout';
|
||||||
import { ArtifactPanel } from './ai/ArtifactPanel';
|
import { ArtifactPanel } from './ai/ArtifactPanel';
|
||||||
@@ -56,6 +56,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
sendMessage: sendToGateway, initStreamListener,
|
sendMessage: sendToGateway, initStreamListener,
|
||||||
chatMode, setChatMode, suggestions,
|
chatMode, setChatMode, suggestions,
|
||||||
totalInputTokens, totalOutputTokens,
|
totalInputTokens, totalOutputTokens,
|
||||||
|
cancelStream,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
const currentAgent = useConversationStore((s) => s.currentAgent);
|
const currentAgent = useConversationStore((s) => s.currentAgent);
|
||||||
const currentModel = useConversationStore((s) => s.currentModel);
|
const currentModel = useConversationStore((s) => s.currentModel);
|
||||||
@@ -571,16 +572,28 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isStreaming ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelStream}
|
||||||
|
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white"
|
||||||
|
aria-label="停止生成"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 text-white fill-white" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={isStreaming || (!input.trim() && pendingFiles.length === 0)}
|
disabled={!input.trim() && pendingFiles.length === 0}
|
||||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
|
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
|
||||||
aria-label="发送消息"
|
aria-label="发送消息"
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4 text-white" />
|
<ArrowUp className="w-4 h-4 text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,57 @@ import { createLogger } from './logger';
|
|||||||
|
|
||||||
const log = createLogger('SaaSRelayGateway');
|
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<string, string>();
|
||||||
|
|
||||||
|
/** 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
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -122,10 +173,12 @@ export function createSaaSRelayGatewayClient(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Build messages array: use history if available, fallback to current message only
|
// 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 history = opts?.history || [];
|
||||||
|
const maskedMessage = maskSensitiveData(message);
|
||||||
const messages = history.length > 0
|
const messages = history.length > 0
|
||||||
? [...history, { role: 'user' as const, content: message }]
|
? [...history, { role: 'user' as const, content: maskedMessage }]
|
||||||
: [{ role: 'user' as const, content: message }];
|
: [{ role: 'user' as const, content: maskedMessage }];
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: getModel() || 'glm-4-flash-250414',
|
model: getModel() || 'glm-4-flash-250414',
|
||||||
@@ -205,9 +258,9 @@ export function createSaaSRelayGatewayClient(
|
|||||||
callbacks.onThinkingDelta?.(delta.reasoning_content);
|
callbacks.onThinkingDelta?.(delta.reasoning_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular content
|
// Handle regular content — unmask tokens so user sees original entities
|
||||||
if (delta?.content) {
|
if (delta?.content) {
|
||||||
callbacks.onDelta(delta.content);
|
callbacks.onDelta(unmaskSensitiveData(delta.content));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for completion
|
// Check for completion
|
||||||
|
|||||||
Reference in New Issue
Block a user