Compare commits
5 Commits
00ebf18f23
...
d7dbdf8600
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7dbdf8600 | ||
|
|
8c25b20fe2 | ||
|
|
87110ffdff | ||
|
|
980a8135fa | ||
|
|
e9e7ffd609 |
@@ -16,6 +16,21 @@ use zclaw_types::Result;
|
||||
use super::pain_aggregator::PainPoint;
|
||||
use super::solution_generator::Proposal;
|
||||
|
||||
/// Brief summary of a stored experience, for suggestion context enrichment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExperienceBrief {
|
||||
pub pain_pattern: String,
|
||||
pub solution_summary: String,
|
||||
pub reuse_count: u32,
|
||||
}
|
||||
|
||||
static EXPERIENCE_EXTRACTOR: std::sync::OnceLock<std::sync::Arc<ExperienceExtractor>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Get the global ExperienceExtractor singleton (if initialized).
|
||||
pub(crate) fn get_experience_extractor() -> Option<std::sync::Arc<ExperienceExtractor>> {
|
||||
EXPERIENCE_EXTRACTOR.get().cloned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared completion status
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -263,6 +278,36 @@ fn xml_escape(s: &str) -> String {
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Initialize the global ExperienceExtractor singleton.
|
||||
/// Called once during app startup, after viking storage is ready.
|
||||
pub async fn init_experience_extractor() -> Result<()> {
|
||||
let sqlite_storage = crate::viking_commands::get_storage().await
|
||||
.map_err(|e| zclaw_types::ZclawError::StorageError(e))?;
|
||||
let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::new(sqlite_storage));
|
||||
let store = std::sync::Arc::new(ExperienceStore::new(viking));
|
||||
let extractor = std::sync::Arc::new(ExperienceExtractor::new(store));
|
||||
EXPERIENCE_EXTRACTOR.set(extractor)
|
||||
.map_err(|_| zclaw_types::ZclawError::StorageError("ExperienceExtractor already initialized".into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find experiences relevant to the current conversation for suggestion enrichment.
|
||||
#[tauri::command]
|
||||
pub async fn experience_find_relevant(
|
||||
agent_id: String,
|
||||
query: String,
|
||||
) -> std::result::Result<Vec<ExperienceBrief>, String> {
|
||||
let extractor = get_experience_extractor()
|
||||
.ok_or("ExperienceExtractor not initialized".to_string())?;
|
||||
let experiences = extractor.find_relevant_experiences(&agent_id, &query).await;
|
||||
Ok(experiences.into_iter().take(3).map(|e| ExperienceBrief {
|
||||
pain_pattern: e.pain_pattern,
|
||||
solution_summary: e.solution_steps.join(";")
|
||||
.chars().take(100).collect(),
|
||||
reuse_count: e.reuse_count,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -407,4 +452,17 @@ mod tests {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
assert_eq!(truncate("这是一个很长的字符串用于测试截断", 10).chars().count(), 11); // 10 + …
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_experience_brief_serialization() {
|
||||
let brief = super::ExperienceBrief {
|
||||
pain_pattern: "报表生成慢".to_string(),
|
||||
solution_summary: "使用 researcher 技能自动收集".to_string(),
|
||||
reuse_count: 3,
|
||||
};
|
||||
let json = serde_json::to_string(&brief).unwrap();
|
||||
let parsed: super::ExperienceBrief = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.pain_pattern, "报表生成慢");
|
||||
assert_eq!(parsed.reuse_count, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,12 @@ pub fn run() {
|
||||
if let Err(e) = rt.block_on(intelligence::pain_aggregator::init_pain_storage(pool)) {
|
||||
tracing::error!("[PainStorage] Init failed: {}, pain points will not persist", e);
|
||||
}
|
||||
|
||||
// Initialize experience extractor for suggestion enrichment.
|
||||
// Graceful degradation: failure does not block app startup.
|
||||
if let Err(e) = rt.block_on(intelligence::experience::init_experience_extractor()) {
|
||||
tracing::warn!("[ExperienceExtractor] Init failed: {}, suggestion context will be empty", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -435,6 +441,8 @@ pub fn run() {
|
||||
intelligence::pain_aggregator::butler_update_proposal_status,
|
||||
// Industry config loader
|
||||
viking_commands::viking_load_industry_keywords,
|
||||
// Experience finder for suggestion enrichment
|
||||
intelligence::experience::experience_find_relevant,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -646,17 +646,21 @@ const HARDCODED_PROMPTS: Record<string, { system: string; user: (arg: string) =>
|
||||
},
|
||||
|
||||
suggestions: {
|
||||
system: `你是对话分析助手。根据最近的对话内容,生成 3 个用户可能想继续探讨的问题。
|
||||
system: `你是对话分析助手和智能管家。根据对话内容和用户画像信息,生成 3 个个性化建议。
|
||||
|
||||
要求:
|
||||
- 每个问题必须与对话内容直接相关,具体且有针对性
|
||||
- 帮助用户深入理解、实际操作或拓展思路
|
||||
- 每个问题不超过 30 个中文字符
|
||||
- 不要重复对话中已讨论过的内容
|
||||
- 使用与用户相同的语言
|
||||
## 生成规则
|
||||
1. 2 条对话续问(深入当前话题,帮助用户继续探索)
|
||||
2. 1 条管家关怀(基于用户消息中提供的痛点、经验或技能信息)
|
||||
- 如果有未解决痛点 → 回访建议,如"上次你提到X,后来解决了吗?"
|
||||
- 如果有相关经验 → 引导复用,如"上次用X方法解决了类似问题,要再试试吗?"
|
||||
- 如果有匹配技能 → 推荐使用,如"你可以试试 [技能名] 来处理这个"
|
||||
- 如果没有提供痛点/经验/技能信息 → 全部生成对话续问
|
||||
3. 每个不超过 30 个中文字符
|
||||
4. 不要重复对话中已讨论过的内容
|
||||
5. 使用与用户相同的语言
|
||||
|
||||
只输出 JSON 数组,包含恰好 3 个字符串。不要输出任何其他内容。
|
||||
示例:["如何在生产环境中部署?", "这个方案的成本如何?", "有没有更简单的替代方案?"]`,
|
||||
示例:["科室绩效分析可以按哪些维度拆解?", "上次的 researcher 技能能用在查房数据整理上吗?", "自动生成合规检查报告的模板有哪些?"]`,
|
||||
user: (context: string) => `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续问题。`,
|
||||
},
|
||||
};
|
||||
|
||||
131
desktop/src/lib/suggestion-context.ts
Normal file
131
desktop/src/lib/suggestion-context.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Suggestion context enrichment — fetches intelligence data for personalized suggestions.
|
||||
* All fetches are optional; failures silently degrade to empty context.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('SuggestionContext');
|
||||
|
||||
const CONTEXT_FETCH_TIMEOUT = 500;
|
||||
|
||||
/** Pain point from butler intelligence layer. */
|
||||
interface PainPoint {
|
||||
summary: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
status: string;
|
||||
occurrence_count: number;
|
||||
}
|
||||
|
||||
/** Brief experience from the experience store. */
|
||||
interface ExperienceBrief {
|
||||
pain_pattern: string;
|
||||
solution_summary: string;
|
||||
reuse_count: number;
|
||||
}
|
||||
|
||||
/** Pipeline/skill match candidate. */
|
||||
interface PipelineCandidateInfo {
|
||||
id: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
category: string | null;
|
||||
match_reason: string | null;
|
||||
}
|
||||
|
||||
/** Route intent response (only NoMatch variant has suggestions). */
|
||||
interface RouteResultResponse {
|
||||
type: 'Matched' | 'Ambiguous' | 'NoMatch' | 'NeedMoreInfo';
|
||||
suggestions?: PipelineCandidateInfo[];
|
||||
}
|
||||
|
||||
/** Aggregated suggestion context from all intelligence sources. */
|
||||
export interface SuggestionContext {
|
||||
userProfile: string;
|
||||
painPoints: string;
|
||||
experiences: string;
|
||||
skillMatch: string;
|
||||
}
|
||||
|
||||
function isTauriAvailable(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>(resolve => setTimeout(() => resolve(null), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fetchUserProfile(agentId: string): Promise<string> {
|
||||
const profile = await invoke<string>('identity_get_file', {
|
||||
agentId,
|
||||
file: 'userprofile',
|
||||
});
|
||||
if (!profile || profile.trim().length === 0) return '';
|
||||
const text = profile.trim();
|
||||
return text.length > 200 ? text.slice(0, 200) : text;
|
||||
}
|
||||
|
||||
async function fetchPainPoints(agentId: string): Promise<string> {
|
||||
const points = await invoke<PainPoint[]>('butler_list_pain_points', { agentId });
|
||||
if (!Array.isArray(points) || points.length === 0) return '';
|
||||
|
||||
const active = points
|
||||
.filter(p => p.confidence >= 0.5 && p.status !== 'Solved' && p.status !== 'Dismissed')
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 3);
|
||||
|
||||
if (active.length === 0) return '';
|
||||
return active
|
||||
.map((p, i) => `${i + 1}. [${p.category}] ${p.summary}(出现${p.occurrence_count}次)`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function fetchExperiences(agentId: string, query: string): Promise<string> {
|
||||
const experiences = await invoke<ExperienceBrief[]>('experience_find_relevant', {
|
||||
agentId,
|
||||
query,
|
||||
});
|
||||
if (!Array.isArray(experiences) || experiences.length === 0) return '';
|
||||
|
||||
return experiences.slice(0, 2)
|
||||
.map(e => `上次解决"${e.pain_pattern}"的方法:${e.solution_summary}(已复用${e.reuse_count}次)`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function fetchSkillMatch(userInput: string): Promise<string> {
|
||||
const result = await invoke<RouteResultResponse>('route_intent', { userInput });
|
||||
const suggestions = result?.suggestions;
|
||||
if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
|
||||
|
||||
const best = suggestions[0];
|
||||
return `你可能需要:${best.display_name} — ${best.description}`;
|
||||
}
|
||||
|
||||
const EMPTY_CONTEXT: SuggestionContext = { userProfile: '', painPoints: '', experiences: '', skillMatch: '' };
|
||||
|
||||
/**
|
||||
* Fetch all intelligence context in parallel for suggestion enrichment.
|
||||
* Returns empty strings for any source that fails — never throws.
|
||||
*/
|
||||
export async function fetchSuggestionContext(
|
||||
agentId: string,
|
||||
lastUserMessage: string,
|
||||
): Promise<SuggestionContext> {
|
||||
if (!isTauriAvailable()) {
|
||||
return EMPTY_CONTEXT;
|
||||
}
|
||||
|
||||
const [userProfile, painPoints, experiences, skillMatch] = await Promise.all([
|
||||
withTimeout(fetchUserProfile(agentId).catch(e => { log.warn('User profile fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||
withTimeout(fetchPainPoints(agentId).catch(e => { log.warn('Pain points fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||
withTimeout(fetchExperiences(agentId, lastUserMessage).catch(e => { log.warn('Experiences fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||
withTimeout(fetchSkillMatch(lastUserMessage).catch(e => { log.warn('Skill match fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||
]);
|
||||
|
||||
return { userProfile: userProfile ?? '', painPoints: painPoints ?? '', experiences: experiences ?? '', skillMatch: skillMatch ?? '' };
|
||||
}
|
||||
@@ -34,8 +34,9 @@ import {
|
||||
} from './conversationStore';
|
||||
import { useMessageStore } from './messageStore';
|
||||
import { useArtifactStore } from './artifactStore';
|
||||
import { llmSuggest } from '../../lib/llm-service';
|
||||
import { llmSuggest, LLM_PROMPTS } from '../../lib/llm-service';
|
||||
import { detectNameSuggestion, detectAgentNameSuggestion } from '../../lib/cold-start-mapper';
|
||||
import { fetchSuggestionContext, type SuggestionContext } from '../../lib/suggestion-context';
|
||||
|
||||
const log = createLogger('StreamStore');
|
||||
|
||||
@@ -399,35 +400,43 @@ function createCompleteHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Async memory extraction (independent — failures don't block name detection)
|
||||
// Parallel: memory extraction + intelligence context fetch
|
||||
const filtered = msgs
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
const convId = useConversationStore.getState().currentConversationId;
|
||||
getMemoryExtractor().extractFromConversation(filtered, agentId, convId ?? undefined)
|
||||
.catch(err => log.warn('Memory extraction failed:', err));
|
||||
const lastUserContent = typeof lastContent === 'string' ? lastContent : '';
|
||||
|
||||
intelligenceClient.reflection.recordConversation().catch(err => {
|
||||
log.warn('Recording conversation failed:', err);
|
||||
});
|
||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
||||
if (shouldReflect) {
|
||||
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
||||
log.warn('Reflection failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
const suggestionContextPromise = fetchSuggestionContext(agentId, lastUserContent);
|
||||
|
||||
// Follow-up suggestions (async LLM call with keyword fallback)
|
||||
const latestMsgs = chat.getMessages() || [];
|
||||
const conversationMessages = latestMsgs
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.filter(m => !m.streaming)
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
// Fire-and-forget background tasks
|
||||
Promise.all([
|
||||
getMemoryExtractor().extractFromConversation(filtered, agentId, convId ?? undefined)
|
||||
.catch(err => log.warn('Memory extraction failed:', err)),
|
||||
intelligenceClient.reflection.recordConversation()
|
||||
.catch(err => log.warn('Recording conversation failed:', err)),
|
||||
suggestionContextPromise,
|
||||
]).then(([, , context]) => {
|
||||
// Conditional reflection (after context is ready)
|
||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
||||
if (shouldReflect) {
|
||||
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
||||
log.warn('Reflection failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
generateLLMSuggestions(conversationMessages, set).catch(err => {
|
||||
log.warn('Suggestion generation error:', err);
|
||||
set({ suggestionsLoading: false });
|
||||
// Follow-up suggestions with enriched context
|
||||
const latestMsgs = chat.getMessages() || [];
|
||||
const conversationMessages = latestMsgs
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.filter(m => !m.streaming)
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
generateLLMSuggestions(conversationMessages, set, context).catch(err => {
|
||||
log.warn('Suggestion generation error:', err);
|
||||
set({ suggestionsLoading: false });
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -559,15 +568,32 @@ function parseSuggestionResponse(raw: string): string[] {
|
||||
async function generateLLMSuggestions(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
set: (partial: Partial<StreamState>) => void,
|
||||
context?: SuggestionContext,
|
||||
): Promise<void> {
|
||||
set({ suggestionsLoading: true });
|
||||
|
||||
try {
|
||||
const recentMessages = messages.slice(-6);
|
||||
const context = recentMessages
|
||||
const conversationContext = recentMessages
|
||||
.map(m => `${m.role === 'user' ? '用户' : '助手'}: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
// Build dynamic user message with intelligence context
|
||||
const ctx = context ?? { userProfile: '', painPoints: '', experiences: '', skillMatch: '' };
|
||||
const hasContext = ctx.userProfile || ctx.painPoints || ctx.experiences || ctx.skillMatch;
|
||||
let userMessage: string;
|
||||
if (hasContext) {
|
||||
const sections: string[] = ['以下是用户的背景信息,请在生成建议时参考:\n'];
|
||||
if (ctx.userProfile) sections.push(`## 用户画像\n${ctx.userProfile}`);
|
||||
if (ctx.painPoints) sections.push(`## 活跃痛点\n${ctx.painPoints}`);
|
||||
if (ctx.experiences) sections.push(`## 相关经验\n${ctx.experiences}`);
|
||||
if (ctx.skillMatch) sections.push(`## 可用技能\n${ctx.skillMatch}`);
|
||||
sections.push(`\n最近对话:\n${conversationContext}`);
|
||||
userMessage = sections.join('\n\n');
|
||||
} else {
|
||||
userMessage = `以下是对话中最近的消息:\n\n${conversationContext}\n\n请生成 3 个后续问题。`;
|
||||
}
|
||||
|
||||
const connectionMode = typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('zclaw-connection-mode')
|
||||
: null;
|
||||
@@ -575,9 +601,9 @@ async function generateLLMSuggestions(
|
||||
let raw: string;
|
||||
|
||||
if (connectionMode === 'saas') {
|
||||
raw = await llmSuggestViaSaaS(context);
|
||||
raw = await llmSuggestViaSaaS(userMessage);
|
||||
} else {
|
||||
raw = await llmSuggest(context);
|
||||
raw = await llmSuggest(userMessage);
|
||||
}
|
||||
|
||||
const suggestions = parseSuggestionResponse(raw);
|
||||
@@ -601,7 +627,7 @@ async function generateLLMSuggestions(
|
||||
* with non-streaming requests. Collects the full response from SSE deltas,
|
||||
* then parses the suggestion JSON from the accumulated text.
|
||||
*/
|
||||
async function llmSuggestViaSaaS(context: string): Promise<string> {
|
||||
async function llmSuggestViaSaaS(userMessage: string): Promise<string> {
|
||||
const { saasClient } = await import('../../lib/saas-client');
|
||||
const { useConversationStore } = await import('./conversationStore');
|
||||
const { useSaaSStore } = await import('../saasStore');
|
||||
@@ -611,9 +637,6 @@ async function llmSuggestViaSaaS(context: string): Promise<string> {
|
||||
const model = currentModel || (availableModels.length > 0 ? availableModels[0]?.id : undefined);
|
||||
if (!model) throw new Error('No model available for suggestions');
|
||||
|
||||
// Delay to avoid concurrent relay requests with memory extraction
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000);
|
||||
|
||||
@@ -623,7 +646,7 @@ async function llmSuggestViaSaaS(context: string): Promise<string> {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: LLM_PROMPTS_SYSTEM },
|
||||
{ role: 'user', content: `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续问题。` },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
max_tokens: 500,
|
||||
temperature: 0.7,
|
||||
@@ -664,17 +687,7 @@ async function llmSuggestViaSaaS(context: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
const LLM_PROMPTS_SYSTEM = `你是对话分析助手。根据最近的对话内容,生成 3 个用户可能想继续探讨的问题。
|
||||
|
||||
要求:
|
||||
- 每个问题必须与对话内容直接相关,具体且有针对性
|
||||
- 帮助用户深入理解、实际操作或拓展思路
|
||||
- 每个问题不超过 30 个中文字符
|
||||
- 不要重复对话中已讨论过的内容
|
||||
- 使用与用户相同的语言
|
||||
|
||||
只输出 JSON 数组,包含恰好 3 个字符串。不要输出任何其他内容。
|
||||
示例:["如何在生产环境中部署?", "这个方案的成本如何?", "有没有更简单的替代方案?"]`;
|
||||
const LLM_PROMPTS_SYSTEM = LLM_PROMPTS.suggestions.system;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatStore injection (avoids circular imports)
|
||||
|
||||
@@ -13,6 +13,13 @@ tags: [log, history]
|
||||
- **fix(desktop)**: `detectAgentNameSuggestion` 从 6 个固定正则改为 trigger+extract 两步法 (10 个 trigger)
|
||||
- **fix(desktop)**: 名字检测从 memory extraction 解耦 — 502 不再阻断面板刷新
|
||||
- **fix(src-tauri)**: `agent_update` 同步写入 soul.md — config.name → system prompt 断链修复
|
||||
|
||||
## [2026-04-23] feat | 动态建议智能化
|
||||
- **feat(src-tauri)**: 新增 `experience_find_relevant` Tauri 命令 + `ExperienceBrief` 结构 + OnceLock 单例
|
||||
- **feat(desktop)**: 新增 `suggestion-context.ts` — 4 路并行拉取智能上下文(用户画像/痛点/经验/技能匹配)
|
||||
- **feat(desktop)**: `streamStore.ts` createCompleteHandler 并行化 + generateLLMSuggestions 增强
|
||||
- **feat(desktop)**: suggestion prompt 改为混合型(2 续问 + 1 管家关怀)
|
||||
- **文件**: experience.rs, lib.rs, suggestion-context.ts, streamStore.ts, llm-service.ts
|
||||
- **refactor(desktop)**: 移除 Agent tab (简洁模式/专业模式),清理 dead code (~280 行)
|
||||
- **验证**: cargo check 0 error, tsc --noEmit 0 error
|
||||
|
||||
|
||||
Reference in New Issue
Block a user