diff --git a/crates/zclaw-growth/src/storage/sqlite.rs b/crates/zclaw-growth/src/storage/sqlite.rs index 15db689..094eb6a 100644 --- a/crates/zclaw-growth/src/storage/sqlite.rs +++ b/crates/zclaw-growth/src/storage/sqlite.rs @@ -132,13 +132,16 @@ impl SqliteStorage { .map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?; // Create FTS5 virtual table for full-text search + // Use trigram tokenizer for CJK (Chinese/Japanese/Korean) support. + // unicode61 cannot tokenize CJK characters, causing memory search to fail. + // trigram indexes overlapping 3-character slices, works well for all languages. sqlx::query( r#" CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( uri, content, keywords, - tokenize='unicode61' + tokenize='trigram' ) "#, ) @@ -189,6 +192,46 @@ impl SqliteStorage { .await .map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?; + // Migration: Rebuild FTS5 table if using old unicode61 tokenizer (can't handle CJK) + // Check tokenizer by inspecting the existing FTS5 table definition + let needs_rebuild: bool = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories_fts' AND sql LIKE '%unicode61%'" + ) + .fetch_one(&self.pool) + .await + .unwrap_or(0) > 0; + + if needs_rebuild { + tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support"); + // Drop old FTS5 table + let _ = sqlx::query("DROP TABLE IF EXISTS memories_fts") + .execute(&self.pool) + .await; + // Recreate with trigram tokenizer + sqlx::query( + r#" + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + uri, + content, + keywords, + tokenize='trigram' + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(format!("Failed to recreate FTS5 table: {}", e)))?; + // Reindex all existing memories into FTS5 + let reindexed = sqlx::query( + "INSERT INTO memories_fts (uri, content, keywords) SELECT uri, content, keywords FROM memories" + ) + .execute(&self.pool) + .await + .map(|r| r.rows_affected()) + .unwrap_or(0); + tracing::info!("[SqliteStorage] FTS5 rebuild complete, reindexed {} entries", reindexed); + } + tracing::info!("[SqliteStorage] Database schema initialized"); Ok(()) } @@ -378,19 +421,37 @@ impl SqliteStorage { /// Strips these and keeps only alphanumeric + CJK tokens with length > 1, /// then joins them with `OR` for broad matching. fn sanitize_fts_query(query: &str) -> String { - let terms: Vec = query - .to_lowercase() - .split(|c: char| !c.is_alphanumeric()) - .filter(|s| !s.is_empty() && s.len() > 1) - .map(|s| s.to_string()) - .collect(); + // trigram tokenizer requires quoted phrases for substring matching + // and needs at least 3 characters per term to produce results. + let lower = query.to_lowercase(); - if terms.is_empty() { - return String::new(); + // Check if query contains CJK characters — trigram handles them natively + let has_cjk = lower.chars().any(|c| { + matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}') + }); + + if has_cjk { + // For CJK, use the full query as a quoted phrase for substring matching + // trigram will match any 3-char subsequence + if lower.len() >= 3 { + format!("\"{}\"", lower) + } else { + String::new() + } + } else { + // For non-CJK, split into terms and join with OR + let terms: Vec = lower + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty() && s.len() > 1) + .map(|s| format!("\"{}\"", s)) + .collect(); + + if terms.is_empty() { + return String::new(); + } + + terms.join(" OR ") } - - // Join with OR so any term can match (broad recall, then rerank by similarity) - terms.join(" OR ") } /// Fetch memories by scope with importance-based ordering. diff --git a/crates/zclaw-saas/src/relay/handlers.rs b/crates/zclaw-saas/src/relay/handlers.rs index a5e9182..8f57b79 100644 --- a/crates/zclaw-saas/src/relay/handlers.rs +++ b/crates/zclaw-saas/src/relay/handlers.rs @@ -378,13 +378,14 @@ pub async fn list_available_models( State(state): State, _ctx: Extension, ) -> SaasResult>> { - // 单次 JOIN 查询替代 2 次全量加载 + // 单次 JOIN 查询 + provider_keys 过滤:仅返回有活跃 API Key 的 provider 下的模型 let rows: Vec<(String, String, String, i64, i64, bool, bool, bool, String)> = sqlx::query_as( - "SELECT m.model_id, m.provider_id, m.alias, m.context_window, + "SELECT DISTINCT m.model_id, m.provider_id, m.alias, m.context_window, m.max_output_tokens, m.supports_streaming, m.supports_vision, m.is_embedding, m.model_type FROM models m INNER JOIN providers p ON m.provider_id = p.id + INNER JOIN provider_keys pk ON pk.provider_id = p.id AND pk.is_active = true WHERE m.enabled = true AND p.enabled = true ORDER BY m.provider_id, m.model_id" ) diff --git a/desktop/src-tauri/src/intelligence/identity.rs b/desktop/src-tauri/src/intelligence/identity.rs index a556f27..e29ac0e 100644 --- a/desktop/src-tauri/src/intelligence/identity.rs +++ b/desktop/src-tauri/src/intelligence/identity.rs @@ -284,18 +284,23 @@ impl AgentIdentityManager { if !identity.instructions.is_empty() { sections.push(identity.instructions.clone()); } - // NOTE: user_profile injection is intentionally disabled. - // The reflection engine may accumulate overly specific details from past - // conversations (e.g., "广东光华", "汕头玩具产业") into user_profile. - // These details then leak into every new conversation's system prompt, - // causing the model to think about old topics instead of the current query. - // Memory injection should only happen via MemoryMiddleware with relevance - // filtering, not unconditionally via user_profile. - // if !identity.user_profile.is_empty() - // && identity.user_profile != default_user_profile() - // { - // sections.push(format!("## 用户画像\n{}", identity.user_profile)); - // } + // Inject user_profile into system prompt for cross-session identity continuity. + // Truncate to first 10 lines to avoid flooding the prompt with overly specific + // details accumulated by the reflection engine. Core identity (name, role) + // is typically in the first few lines. + if !identity.user_profile.is_empty() + && identity.user_profile != default_user_profile() + { + let truncated: String = identity + .user_profile + .lines() + .take(10) + .collect::>() + .join("\n"); + if !truncated.is_empty() { + sections.push(format!("## 用户画像\n{}", truncated)); + } + } if let Some(ctx) = memory_context { sections.push(ctx.to_string()); } diff --git a/desktop/src/components/ButlerPanel/MemorySection.tsx b/desktop/src/components/ButlerPanel/MemorySection.tsx index 2ef9545..9d7f99d 100644 --- a/desktop/src/components/ButlerPanel/MemorySection.tsx +++ b/desktop/src/components/ButlerPanel/MemorySection.tsx @@ -4,6 +4,7 @@ import { listVikingResources } from '../../lib/viking-client'; interface MemorySectionProps { agentId: string; + refreshKey?: number; } interface MemoryEntry { @@ -12,7 +13,7 @@ interface MemoryEntry { resourceType: string; } -export function MemorySection({ agentId }: MemorySectionProps) { +export function MemorySection({ agentId, refreshKey }: MemorySectionProps) { const [memories, setMemories] = useState([]); const [loading, setLoading] = useState(false); @@ -20,7 +21,8 @@ export function MemorySection({ agentId }: MemorySectionProps) { if (!agentId) return; setLoading(true); - listVikingResources(`viking://agent/${agentId}/memories/`) + // 查询 agent:// 下的所有记忆资源 (preferences/knowledge/experience/sessions) + listVikingResources(`agent://${agentId}/`) .then((entries) => { setMemories(entries as MemoryEntry[]); }) @@ -29,7 +31,7 @@ export function MemorySection({ agentId }: MemorySectionProps) { setMemories([]); }) .finally(() => setLoading(false)); - }, [agentId]); + }, [agentId, refreshKey]); if (loading) { return ( diff --git a/desktop/src/components/ButlerPanel/index.tsx b/desktop/src/components/ButlerPanel/index.tsx index 7d9c9c5..f41b4f8 100644 --- a/desktop/src/components/ButlerPanel/index.tsx +++ b/desktop/src/components/ButlerPanel/index.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useButlerInsights } from '../../hooks/useButlerInsights'; import { useChatStore } from '../../store/chatStore'; import { useIndustryStore } from '../../store/industryStore'; +import { extractAndStoreMemories } from '../../lib/viking-client'; import { InsightsSection } from './InsightsSection'; import { ProposalsSection } from './ProposalsSection'; import { MemorySection } from './MemorySection'; @@ -15,6 +16,7 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) { const messageCount = useChatStore((s) => s.messages.length); const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore(); const [analyzing, setAnalyzing] = useState(false); + const [memoryRefreshKey, setMemoryRefreshKey] = useState(0); // Auto-fetch industry configs once per session useEffect(() => { @@ -26,15 +28,30 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) { const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0; const canAnalyze = messageCount >= 2; - const handleAnalyze = async () => { - if (!canAnalyze || analyzing) return; + const handleAnalyze = useCallback(async () => { + if (!canAnalyze || analyzing || !agentId) return; setAnalyzing(true); try { + // 1. Refresh pain points & proposals await refresh(); + + // 2. Extract and store memories from current conversation + const messages = useChatStore.getState().messages; + if (messages.length >= 2) { + const extractionMessages = messages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: typeof m.content === 'string' ? m.content : '', + })); + await extractAndStoreMemories(extractionMessages, agentId); + // Trigger MemorySection to reload + setMemoryRefreshKey((k) => k + 1); + } + } catch { + // Extraction failure should not block UI — insights still refreshed } finally { setAnalyzing(false); } - }; + }, [canAnalyze, analyzing, agentId, refresh]); if (!agentId) { return ( @@ -107,7 +124,7 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {

我记得关于您

- + {/* Industry section */} diff --git a/desktop/src/components/ScenarioTags.tsx b/desktop/src/components/ScenarioTags.tsx index 5b270ba..699957e 100644 --- a/desktop/src/components/ScenarioTags.tsx +++ b/desktop/src/components/ScenarioTags.tsx @@ -10,6 +10,10 @@ import { Package, BarChart, Palette, + HeartPulse, + GraduationCap, + Landmark, + Scale, Server, Search, Megaphone, @@ -33,6 +37,10 @@ const iconMap: Record> = { Package, BarChart, Palette, + HeartPulse, + GraduationCap, + Landmark, + Scale, Server, Search, Megaphone, diff --git a/desktop/src/components/SimpleSidebar.tsx b/desktop/src/components/SimpleSidebar.tsx index 792626a..2cb8eb9 100644 --- a/desktop/src/components/SimpleSidebar.tsx +++ b/desktop/src/components/SimpleSidebar.tsx @@ -7,10 +7,11 @@ import { useState } from 'react'; import { - Settings, LayoutGrid, + Settings, LayoutGrid, SquarePen, Search, X, } from 'lucide-react'; import { ConversationList } from './ConversationList'; +import { useChatStore } from '../store/chatStore'; interface SimpleSidebarProps { onOpenSettings?: () => void; @@ -19,6 +20,11 @@ interface SimpleSidebarProps { export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) { const [searchQuery, setSearchQuery] = useState(''); + const newConversation = useChatStore((s) => s.newConversation); + + const handleNewConversation = () => { + newConversation(); + }; return (