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
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:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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: '其他',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user