fix(agent): 12 项 agent 对话链路全栈修复
Some checks are pending
CI / Lint & TypeCheck (push) Waiting to run
CI / Unit Tests (push) Waiting to run
CI / Build Frontend (push) Waiting to run
CI / Rust Check (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / E2E Tests (push) Blocked by required conditions

深端到端验证发现 12 个问题,6 Phase 全栈修复:

Phase 5 — 快速 UX 修复:
- #9: SimpleSidebar 添加新对话按钮 (SquarePen + useChatStore)
- #5: 模型列表 JOIN provider_keys 过滤无 API Key 的模型
- #11: AgentOnboardingWizard 焦点领域增加 4 行业选项
  (医疗健康/教育培训/金融财务/法律合规)

Phase 1 — ButlerPanel 记忆修复:
- #2a: MemorySection URI 从 viking://agent/.../memories/ 修正为 agent://.../
- #2b: "立即分析对话"按钮现在触发 extractAndStoreMemories

Phase 2 — FTS5 中文分词:
- #4: FTS5 tokenizer 从 unicode61 切换到 trigram,原生支持 CJK
- 自动迁移:检测旧 unicode61 表并重建索引
- sanitize_fts_query 支持中文引号短语查询

Phase 3 — 跨会话身份持久化:
- #6-8: 重新启用 USER.md 注入系统提示词 (截断前 10 行)

Phase 4 — Agent 面板同步:
- #1,#10: listClones 从 4 字段扩展到完整映射
  (soul/userProfile 解析 nickname/emoji/userName/userRole)
- updateClone 通过 identity 系统同步 nickname→SOUL.md
  和 userName/userRole→USER.md

Phase 6 — Agent 创建容错:
- #12: createFromTemplate 增加 SaaS 不可用 fallback

验证: tsc --noEmit  cargo check 
This commit is contained in:
iven
2026-04-16 09:21:46 +08:00
parent 08af78aa83
commit 3c01754c40
10 changed files with 330 additions and 44 deletions

View File

@@ -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<String> = 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<String> = 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.

View File

@@ -378,13 +378,14 @@ pub async fn list_available_models(
State(state): State<AppState>,
_ctx: Extension<AuthContext>,
) -> SaasResult<Json<Vec<serde_json::Value>>> {
// 单次 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"
)

View File

@@ -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::<Vec<_>>()
.join("\n");
if !truncated.is_empty() {
sections.push(format!("## 用户画像\n{}", truncated));
}
}
if let Some(ctx) = memory_context {
sections.push(ctx.to_string());
}

View File

@@ -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<MemoryEntry[]>([]);
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 (

View File

@@ -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) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
</h3>
<MemorySection agentId={agentId} />
<MemorySection agentId={agentId} refreshKey={memoryRefreshKey} />
</div>
{/* Industry section */}

View File

@@ -10,6 +10,10 @@ import {
Package,
BarChart,
Palette,
HeartPulse,
GraduationCap,
Landmark,
Scale,
Server,
Search,
Megaphone,
@@ -33,6 +37,10 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Package,
BarChart,
Palette,
HeartPulse,
GraduationCap,
Landmark,
Scale,
Server,
Search,
Megaphone,

View File

@@ -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 (
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0">
@@ -27,11 +33,26 @@ export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarPro
<span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
ZCLAW
</span>
<button
onClick={handleNewConversation}
className="ml-auto p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-md transition-colors text-gray-600 dark:text-gray-400"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-hidden">
<div className="p-2 h-full overflow-y-auto">
{/* 新对话按钮 */}
<button
onClick={handleNewConversation}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-black/10 dark:hover:bg-white/10 transition-colors mb-2"
>
<SquarePen className="w-4 h-4" />
</button>
{/* 搜索框 */}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />

View File

@@ -56,16 +56,63 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
/**
* List clones — maps to listAgents() with field adaptation
* Maps all available AgentInfo fields to Clone interface properties
*/
proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
const agents = await this.listAgents();
const clones = agents.map((agent) => ({
id: agent.id,
name: agent.name,
role: agent.description,
model: agent.model,
createdAt: new Date().toISOString(),
}));
const clones = agents.map((agent) => {
// Parse personality/emoji/nickname from SOUL.md content
const soulLines = (agent.soul || '').split('\n');
let emoji: string | undefined;
let personality: string | undefined;
let nickname: string | undefined;
for (const line of soulLines) {
if (!emoji || !nickname) {
// Parse header line: "> 🦞 Nickname" or "> 🦞"
const headerMatch = line.match(/^>\s*(\p{Emoji_Presentation}|\p{Extended_Pictographic})?\s*(.+)$/u);
if (headerMatch) {
if (headerMatch[1] && !emoji) emoji = headerMatch[1];
if (headerMatch[2]?.trim() && !nickname) nickname = headerMatch[2].trim();
}
// Also check emoji without nickname
if (!emoji) {
const emojiOnly = line.match(/^>\s*(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*$/u);
if (emojiOnly) emoji = emojiOnly[1];
}
}
if (!personality) {
const match = line.match(/##\s*(?:性格|核心特质|沟通风格)/);
if (match) personality = line.trim();
}
}
// Parse userName/userRole from userProfile
let userName: string | undefined;
let userRole: string | undefined;
if (agent.userProfile && typeof agent.userProfile === 'object') {
const profile = agent.userProfile as Record<string, unknown>;
userName = profile.userName as string | undefined || profile.name as string | undefined;
userRole = profile.userRole as string | undefined || profile.role as string | undefined;
}
return {
id: agent.id,
name: agent.name,
role: agent.description,
nickname,
model: agent.model,
soul: agent.soul,
systemPrompt: agent.systemPrompt,
temperature: agent.temperature,
maxTokens: agent.maxTokens,
emoji,
personality,
userName,
userRole,
createdAt: agent.createdAt || new Date().toISOString(),
updatedAt: agent.updatedAt,
};
});
return { clones };
};
@@ -119,7 +166,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
};
/**
* Update clone — maps to kernel agent_update
* Update clone — maps to kernel agent_update + identity system for nickname/userName
*/
proto.updateClone = async function (this: KernelClient, id: string, updates: Record<string, unknown>): Promise<{ clone: unknown }> {
await invoke('agent_update', {
@@ -135,15 +182,90 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
},
});
// Sync nickname/emoji to SOUL.md via identity system
const nickname = updates.nickname as string | undefined;
const emoji = updates.emoji as string | undefined;
if (nickname || emoji) {
try {
const currentSoul = await invoke<string | null>('identity_get_file', { agentId: id, file: 'soul' });
const soul = currentSoul || '';
// Inject or update nickname line in SOUL.md header
const lines = soul.split('\n');
const headerIdx = lines.findIndex((l: string) => l.startsWith('> '));
if (headerIdx >= 0) {
// Update existing header line
let header = lines[headerIdx];
if (emoji && !header.match(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/u)) {
header = `> ${emoji} ${header.slice(2)}`;
}
lines[headerIdx] = header;
} else if (emoji || nickname) {
// Add header line after title
const label = nickname || '';
const icon = emoji || '';
const titleIdx = lines.findIndex((l: string) => l.startsWith('# '));
if (titleIdx >= 0) {
lines.splice(titleIdx + 1, 0, `> ${icon} ${label}`.trim());
}
}
await invoke('identity_update_file', { agentId: id, file: 'soul', content: lines.join('\n') });
} catch {
// Identity system update is non-critical
}
}
// Sync userName/userRole to USER.md via identity system
const userName = updates.userName as string | undefined;
const userRole = updates.userRole as string | undefined;
if (userName || userRole) {
try {
const currentProfile = await invoke<string | null>('identity_get_file', { agentId: id, file: 'user_profile' });
const profile = currentProfile || '# 用户档案\n';
const profileLines = profile.split('\n');
// Update or add userName
if (userName) {
const nameIdx = profileLines.findIndex((l: string) => l.includes('姓名') || l.includes('userName'));
if (nameIdx >= 0) {
profileLines[nameIdx] = `- 姓名:${userName}`;
} else {
const sectionIdx = profileLines.findIndex((l: string) => l.startsWith('## 基本信息'));
if (sectionIdx >= 0) {
profileLines.splice(sectionIdx + 1, 0, '', `- 姓名:${userName}`);
} else {
profileLines.push('', '## 基本信息', '', `- 姓名:${userName}`);
}
}
}
// Update or add userRole
if (userRole) {
const roleIdx = profileLines.findIndex((l: string) => l.includes('角色') || l.includes('userRole'));
if (roleIdx >= 0) {
profileLines[roleIdx] = `- 角色:${userRole}`;
} else {
profileLines.push(`- 角色:${userRole}`);
}
}
await invoke('identity_update_file', { agentId: id, file: 'user_profile', content: profileLines.join('\n') });
} catch {
// Identity system update is non-critical
}
}
// Return updated clone representation
const clone = {
id,
name: updates.name,
role: updates.description || updates.role,
nickname: updates.nickname,
model: updates.model,
emoji: updates.emoji,
personality: updates.personality,
communicationStyle: updates.communicationStyle,
systemPrompt: updates.systemPrompt,
userName: updates.userName,
userRole: updates.userRole,
};
return { clone };
};

View File

@@ -97,6 +97,27 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
icon: 'Palette',
keywords: ['设计', 'UI', 'UX', '视觉', '原型', '界面'],
},
{
id: 'healthcare',
label: '医疗健康',
description: '医院管理、患者服务、医疗数据分析',
icon: 'HeartPulse',
keywords: ['医疗', '医院', '健康', '患者', '临床', '护理', '行政'],
},
{
id: 'education',
label: '教育培训',
description: '课程设计、教学辅助、学习规划',
icon: 'GraduationCap',
keywords: ['教育', '教学', '课程', '培训', '学习', '考试'],
},
{
id: 'finance',
label: '金融财务',
description: '财务分析、风险管理、投资研究',
icon: 'Landmark',
keywords: ['金融', '财务', '投资', '风控', '审计', '报表'],
},
{
id: 'devops',
label: '运维部署',
@@ -118,6 +139,13 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
icon: 'Megaphone',
keywords: ['营销', '推广', '运营', '社媒', '增长', '转化'],
},
{
id: 'legal',
label: '法律合规',
description: '合同审查、法规研究、合规管理',
icon: 'Scale',
keywords: ['法律', '合同', '合规', '法规', '审查', '风险'],
},
{
id: 'other',
label: '其他',

View File

@@ -204,7 +204,28 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
// Step 1: Call backend to get server-processed config (tools merge)
const config = await saasClient.createAgentFromTemplate(template.id);
// Fallback to template data directly if SaaS is unreachable
let config;
try {
config = await saasClient.createAgentFromTemplate(template.id);
} catch (saasErr) {
log.warn('[AgentStore] SaaS createAgentFromTemplate failed, using template directly:', saasErr);
// Fallback: build config from template data without server-side tools merge
config = {
name: template.name,
model: template.model,
system_prompt: template.system_prompt,
tools: template.tools || [],
soul_content: template.soul_content,
welcome_message: template.welcome_message,
quick_commands: template.quick_commands,
temperature: template.temperature,
max_tokens: template.max_tokens,
personality: template.personality,
communication_style: template.communication_style,
emoji: template.emoji,
};
}
// Resolve model: template model > first available SaaS model > 'default'
const resolvedModel = config.model