fix(agent): 12 项 agent 对话链路全栈修复
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

深端到端验证发现 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)))?; .map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?;
// Create FTS5 virtual table for full-text search // 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( sqlx::query(
r#" r#"
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
uri, uri,
content, content,
keywords, keywords,
tokenize='unicode61' tokenize='trigram'
) )
"#, "#,
) )
@@ -189,6 +192,46 @@ impl SqliteStorage {
.await .await
.map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?; .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"); tracing::info!("[SqliteStorage] Database schema initialized");
Ok(()) Ok(())
} }
@@ -378,20 +421,38 @@ impl SqliteStorage {
/// Strips these and keeps only alphanumeric + CJK tokens with length > 1, /// Strips these and keeps only alphanumeric + CJK tokens with length > 1,
/// then joins them with `OR` for broad matching. /// then joins them with `OR` for broad matching.
fn sanitize_fts_query(query: &str) -> String { fn sanitize_fts_query(query: &str) -> String {
let terms: Vec<String> = query // trigram tokenizer requires quoted phrases for substring matching
.to_lowercase() // and needs at least 3 characters per term to produce results.
let lower = query.to_lowercase();
// 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()) .split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty() && s.len() > 1) .filter(|s| !s.is_empty() && s.len() > 1)
.map(|s| s.to_string()) .map(|s| format!("\"{}\"", s))
.collect(); .collect();
if terms.is_empty() { if terms.is_empty() {
return String::new(); return String::new();
} }
// Join with OR so any term can match (broad recall, then rerank by similarity)
terms.join(" OR ") terms.join(" OR ")
} }
}
/// Fetch memories by scope with importance-based ordering. /// Fetch memories by scope with importance-based ordering.
/// Used internally by find() for scope-based queries. /// Used internally by find() for scope-based queries.

View File

@@ -378,13 +378,14 @@ pub async fn list_available_models(
State(state): State<AppState>, State(state): State<AppState>,
_ctx: Extension<AuthContext>, _ctx: Extension<AuthContext>,
) -> SaasResult<Json<Vec<serde_json::Value>>> { ) -> 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( 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.max_output_tokens, m.supports_streaming, m.supports_vision,
m.is_embedding, m.model_type m.is_embedding, m.model_type
FROM models m FROM models m
INNER JOIN providers p ON m.provider_id = p.id 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 WHERE m.enabled = true AND p.enabled = true
ORDER BY m.provider_id, m.model_id" ORDER BY m.provider_id, m.model_id"
) )

View File

@@ -284,18 +284,23 @@ impl AgentIdentityManager {
if !identity.instructions.is_empty() { if !identity.instructions.is_empty() {
sections.push(identity.instructions.clone()); sections.push(identity.instructions.clone());
} }
// NOTE: user_profile injection is intentionally disabled. // Inject user_profile into system prompt for cross-session identity continuity.
// The reflection engine may accumulate overly specific details from past // Truncate to first 10 lines to avoid flooding the prompt with overly specific
// conversations (e.g., "广东光华", "汕头玩具产业") into user_profile. // details accumulated by the reflection engine. Core identity (name, role)
// These details then leak into every new conversation's system prompt, // is typically in the first few lines.
// causing the model to think about old topics instead of the current query. if !identity.user_profile.is_empty()
// Memory injection should only happen via MemoryMiddleware with relevance && identity.user_profile != default_user_profile()
// filtering, not unconditionally via user_profile. {
// if !identity.user_profile.is_empty() let truncated: String = identity
// && identity.user_profile != default_user_profile() .user_profile
// { .lines()
// sections.push(format!("## 用户画像\n{}", identity.user_profile)); .take(10)
// } .collect::<Vec<_>>()
.join("\n");
if !truncated.is_empty() {
sections.push(format!("## 用户画像\n{}", truncated));
}
}
if let Some(ctx) = memory_context { if let Some(ctx) = memory_context {
sections.push(ctx.to_string()); sections.push(ctx.to_string());
} }

View File

@@ -4,6 +4,7 @@ import { listVikingResources } from '../../lib/viking-client';
interface MemorySectionProps { interface MemorySectionProps {
agentId: string; agentId: string;
refreshKey?: number;
} }
interface MemoryEntry { interface MemoryEntry {
@@ -12,7 +13,7 @@ interface MemoryEntry {
resourceType: string; resourceType: string;
} }
export function MemorySection({ agentId }: MemorySectionProps) { export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
const [memories, setMemories] = useState<MemoryEntry[]>([]); const [memories, setMemories] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -20,7 +21,8 @@ export function MemorySection({ agentId }: MemorySectionProps) {
if (!agentId) return; if (!agentId) return;
setLoading(true); setLoading(true);
listVikingResources(`viking://agent/${agentId}/memories/`) // 查询 agent:// 下的所有记忆资源 (preferences/knowledge/experience/sessions)
listVikingResources(`agent://${agentId}/`)
.then((entries) => { .then((entries) => {
setMemories(entries as MemoryEntry[]); setMemories(entries as MemoryEntry[]);
}) })
@@ -29,7 +31,7 @@ export function MemorySection({ agentId }: MemorySectionProps) {
setMemories([]); setMemories([]);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [agentId]); }, [agentId, refreshKey]);
if (loading) { if (loading) {
return ( return (

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useButlerInsights } from '../../hooks/useButlerInsights'; import { useButlerInsights } from '../../hooks/useButlerInsights';
import { useChatStore } from '../../store/chatStore'; import { useChatStore } from '../../store/chatStore';
import { useIndustryStore } from '../../store/industryStore'; import { useIndustryStore } from '../../store/industryStore';
import { extractAndStoreMemories } from '../../lib/viking-client';
import { InsightsSection } from './InsightsSection'; import { InsightsSection } from './InsightsSection';
import { ProposalsSection } from './ProposalsSection'; import { ProposalsSection } from './ProposalsSection';
import { MemorySection } from './MemorySection'; import { MemorySection } from './MemorySection';
@@ -15,6 +16,7 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
const messageCount = useChatStore((s) => s.messages.length); const messageCount = useChatStore((s) => s.messages.length);
const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore(); const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore();
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [memoryRefreshKey, setMemoryRefreshKey] = useState(0);
// Auto-fetch industry configs once per session // Auto-fetch industry configs once per session
useEffect(() => { useEffect(() => {
@@ -26,15 +28,30 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0; const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0;
const canAnalyze = messageCount >= 2; const canAnalyze = messageCount >= 2;
const handleAnalyze = async () => { const handleAnalyze = useCallback(async () => {
if (!canAnalyze || analyzing) return; if (!canAnalyze || analyzing || !agentId) return;
setAnalyzing(true); setAnalyzing(true);
try { try {
// 1. Refresh pain points & proposals
await refresh(); 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 { } finally {
setAnalyzing(false); setAnalyzing(false);
} }
}; }, [canAnalyze, analyzing, agentId, refresh]);
if (!agentId) { if (!agentId) {
return ( 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 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
</h3> </h3>
<MemorySection agentId={agentId} /> <MemorySection agentId={agentId} refreshKey={memoryRefreshKey} />
</div> </div>
{/* Industry section */} {/* Industry section */}

View File

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

View File

@@ -7,10 +7,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Settings, LayoutGrid, Settings, LayoutGrid, SquarePen,
Search, X, Search, X,
} from 'lucide-react'; } from 'lucide-react';
import { ConversationList } from './ConversationList'; import { ConversationList } from './ConversationList';
import { useChatStore } from '../store/chatStore';
interface SimpleSidebarProps { interface SimpleSidebarProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
@@ -19,6 +20,11 @@ interface SimpleSidebarProps {
export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) { export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const newConversation = useChatStore((s) => s.newConversation);
const handleNewConversation = () => {
newConversation();
};
return ( return (
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0"> <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"> <span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
ZCLAW ZCLAW
</span> </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>
{/* 内容区域 */} {/* 内容区域 */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="p-2 h-full overflow-y-auto"> <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"> <div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" /> <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 * 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[] }> { proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
const agents = await this.listAgents(); const agents = await this.listAgents();
const clones = agents.map((agent) => ({ 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, id: agent.id,
name: agent.name, name: agent.name,
role: agent.description, role: agent.description,
nickname,
model: agent.model, model: agent.model,
createdAt: new Date().toISOString(), 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 }; 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 }> { proto.updateClone = async function (this: KernelClient, id: string, updates: Record<string, unknown>): Promise<{ clone: unknown }> {
await invoke('agent_update', { 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 // Return updated clone representation
const clone = { const clone = {
id, id,
name: updates.name, name: updates.name,
role: updates.description || updates.role, role: updates.description || updates.role,
nickname: updates.nickname,
model: updates.model, model: updates.model,
emoji: updates.emoji,
personality: updates.personality, personality: updates.personality,
communicationStyle: updates.communicationStyle, communicationStyle: updates.communicationStyle,
systemPrompt: updates.systemPrompt, systemPrompt: updates.systemPrompt,
userName: updates.userName,
userRole: updates.userRole,
}; };
return { clone }; return { clone };
}; };

View File

@@ -97,6 +97,27 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
icon: 'Palette', icon: 'Palette',
keywords: ['设计', 'UI', 'UX', '视觉', '原型', '界面'], 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', id: 'devops',
label: '运维部署', label: '运维部署',
@@ -118,6 +139,13 @@ export const SCENARIO_TAGS: ScenarioTag[] = [
icon: 'Megaphone', icon: 'Megaphone',
keywords: ['营销', '推广', '运营', '社媒', '增长', '转化'], keywords: ['营销', '推广', '运营', '社媒', '增长', '转化'],
}, },
{
id: 'legal',
label: '法律合规',
description: '合同审查、法规研究、合规管理',
icon: 'Scale',
keywords: ['法律', '合同', '合规', '法规', '审查', '风险'],
},
{ {
id: 'other', id: 'other',
label: '其他', label: '其他',

View File

@@ -204,7 +204,28 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
// Step 1: Call backend to get server-processed config (tools merge) // 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' // Resolve model: template model > first available SaaS model > 'default'
const resolvedModel = config.model const resolvedModel = config.model