From d0af9755c65638940922219c5bf928e33a5dca8f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 22 Nov 2025 18:14:23 +0800 Subject: [PATCH] feat: enhance personal space experience --- modules/personalization_manager.py | 174 ++++++++++++ static/app.js | 205 +++++++++++++- static/index.html | 158 ++++++++++- static/style.css | 419 +++++++++++++++++++++++++++-- 4 files changed, 927 insertions(+), 29 deletions(-) create mode 100644 modules/personalization_manager.py diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py new file mode 100644 index 0000000..f16a23a --- /dev/null +++ b/modules/personalization_manager.py @@ -0,0 +1,174 @@ +"""Utilities for managing per-user personalization settings.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Union + +PERSONALIZATION_FILENAME = "personalization.json" +MAX_SHORT_FIELD_LENGTH = 20 +MAX_CONSIDERATION_LENGTH = 50 +MAX_CONSIDERATION_ITEMS = 10 +TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"] + +DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { + "enabled": False, + "self_identify": "", + "user_name": "", + "profession": "", + "tone": "", + "considerations": [], +} + +__all__ = [ + "PERSONALIZATION_FILENAME", + "DEFAULT_PERSONALIZATION_CONFIG", + "TONE_PRESETS", + "MAX_CONSIDERATION_ITEMS", + "load_personalization_config", + "save_personalization_config", + "ensure_personalization_config", + "build_personalization_prompt", + "sanitize_personalization_payload", +] + + +PathLike = Union[str, Path] + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _to_path(base: PathLike) -> Path: + base_path = Path(base).expanduser() + if base_path.is_dir(): + return base_path / PERSONALIZATION_FILENAME + return base_path + + +def ensure_personalization_config(base_dir: PathLike) -> Dict[str, Any]: + """Ensure the personalization file exists and return its content.""" + path = _to_path(base_dir) + _ensure_parent(path) + if not path.exists(): + with open(path, "w", encoding="utf-8") as f: + json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2) + return deepcopy(DEFAULT_PERSONALIZATION_CONFIG) + return load_personalization_config(base_dir) + + +def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]: + """Load personalization config; fall back to defaults on errors.""" + path = _to_path(base_dir) + _ensure_parent(path) + if not path.exists(): + return ensure_personalization_config(base_dir) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return sanitize_personalization_payload(data) + except (json.JSONDecodeError, OSError): + # 重置为默认配置,避免错误阻塞 + with open(path, "w", encoding="utf-8") as f: + json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2) + return deepcopy(DEFAULT_PERSONALIZATION_CONFIG) + + +def sanitize_personalization_payload( + payload: Optional[Dict[str, Any]], + fallback: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Normalize payload structure and clamp field lengths.""" + base = deepcopy(DEFAULT_PERSONALIZATION_CONFIG) + if fallback: + base.update(fallback) + data = payload or {} + + def _resolve_short_field(key: str) -> str: + if key in data: + return _sanitize_short_field(data.get(key)) + return _sanitize_short_field(base.get(key)) + + base["enabled"] = bool(data.get("enabled", base["enabled"])) + base["self_identify"] = _resolve_short_field("self_identify") + base["user_name"] = _resolve_short_field("user_name") + base["profession"] = _resolve_short_field("profession") + base["tone"] = _resolve_short_field("tone") + if "considerations" in data: + base["considerations"] = _sanitize_considerations(data.get("considerations")) + else: + base["considerations"] = _sanitize_considerations(base.get("considerations", [])) + return base + + +def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) -> Dict[str, Any]: + """Persist sanitized personalization config and return it.""" + existing = load_personalization_config(base_dir) + config = sanitize_personalization_payload(payload, fallback=existing) + path = _to_path(base_dir) + _ensure_parent(path) + with open(path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + return config + + +def build_personalization_prompt( + config: Optional[Dict[str, Any]], + include_header: bool = True +) -> Optional[str]: + """Generate the personalization prompt text based on config.""" + if not config or not config.get("enabled"): + return None + + lines = [] + if include_header: + lines.append("用户的个性化数据,请回答时务必参照这些信息") + + if config.get("self_identify"): + lines.append(f"用户希望你自称:{config['self_identify']}") + if config.get("user_name"): + lines.append(f"用户希望你称呼为:{config['user_name']}") + if config.get("profession"): + lines.append(f"用户的职业是:{config['profession']}") + if config.get("tone"): + lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流") + + considerations: Iterable[str] = config.get("considerations") or [] + considerations = [item for item in considerations if item] + if considerations: + lines.append("用户希望你在回答问题时必须考虑的信息是:") + for idx, item in enumerate(considerations, 1): + lines.append(f"{idx}. {item}") + + if len(lines) == (1 if include_header else 0): + # 没有任何有效内容时不注入 + return None + return "\n".join(lines) + + +def _sanitize_short_field(value: Optional[str]) -> str: + if not value: + return "" + text = str(value).strip() + if not text: + return "" + return text[:MAX_SHORT_FIELD_LENGTH] + + +def _sanitize_considerations(value: Any) -> list: + if not isinstance(value, list): + return [] + cleaned = [] + for item in value: + if not isinstance(item, str): + continue + text = item.strip() + if not text: + continue + cleaned.append(text[:MAX_CONSIDERATION_LENGTH]) + if len(cleaned) >= MAX_CONSIDERATION_ITEMS: + break + return cleaned diff --git a/static/app.js b/static/app.js index a0aabd1..128d5cd 100644 --- a/static/app.js +++ b/static/app.js @@ -254,6 +254,24 @@ async function bootstrapApp() { currentConversationId: null, currentConversationTitle: '当前对话', personalPageVisible: false, + personalizationLoading: false, + personalizationSaving: false, + personalizationLoaded: false, + personalizationStatus: '', + personalizationError: '', + personalizationMaxConsiderations: 10, + overlayPressActive: false, + personalForm: { + enabled: false, + self_identify: '', + user_name: '', + profession: '', + tone: '', + considerations: [] + }, + tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'], + newConsideration: '', + draggedConsiderationIndex: null, // 搜索功能 searchQuery: '', @@ -865,11 +883,12 @@ async function bootstrapApp() { lastAction.content += data.content; } this.$forceUpdate(); - if (lastAction && lastAction.blockId) { - this.$nextTick(() => this.scrollThinkingToBottom(lastAction.blockId)); - } else { + this.$nextTick(() => { + if (lastAction && lastAction.blockId) { + this.scrollThinkingToBottom(lastAction.blockId); + } this.conditionalScrollToBottom(); - } + }); } }); @@ -1606,6 +1625,9 @@ async function bootstrapApp() { } else { this.conversations.push(...data.data.conversations); } + if (this.currentConversationId) { + this.promoteConversationToTop(this.currentConversationId); + } this.hasMoreConversations = data.data.has_more; console.log(`已加载 ${this.conversations.length} 个对话`); @@ -2143,12 +2165,185 @@ async function bootstrapApp() { this.sidebarCollapsed = !this.sidebarCollapsed; }, - openPersonalPage() { + async openPersonalPage() { this.personalPageVisible = true; + if (!this.personalizationLoaded && !this.personalizationLoading) { + await this.fetchPersonalization(); + } }, closePersonalPage() { this.personalPageVisible = false; + this.draggedConsiderationIndex = null; + this.overlayPressActive = false; + }, + + handleOverlayPressStart(event) { + if (event && event.type === 'mousedown' && event.button !== 0) { + return; + } + this.overlayPressActive = true; + }, + + handleOverlayPressEnd() { + if (!this.overlayPressActive) { + return; + } + this.overlayPressActive = false; + this.closePersonalPage(); + }, + + handleOverlayPressCancel() { + this.overlayPressActive = false; + }, + + async handleLogout() { + try { + const resp = await fetch('/logout', { method: 'POST' }); + let result = {}; + try { + result = await resp.json(); + } catch (err) { + result = {}; + } + if (!resp.ok || (result && result.success === false)) { + const message = (result && (result.error || result.message)) || '退出失败'; + throw new Error(message); + } + window.location.href = '/login'; + } catch (error) { + console.error('退出登录失败:', error); + alert(`退出登录失败:${error.message || '请稍后重试'}`); + } + }, + + async fetchPersonalization() { + this.personalizationLoading = true; + this.personalizationError = ''; + try { + const resp = await fetch('/api/personalization'); + const result = await resp.json(); + if (!resp.ok || !result.success) { + throw new Error(result.error || '加载失败'); + } + this.applyPersonalizationData(result.data || {}); + this.personalizationLoaded = true; + } catch (error) { + this.personalizationError = error.message || '加载失败'; + alert(`加载个性化配置失败:${this.personalizationError}`); + } finally { + this.personalizationLoading = false; + } + }, + + applyPersonalizationData(data) { + this.personalForm = { + enabled: !!data.enabled, + self_identify: data.self_identify || '', + user_name: data.user_name || '', + profession: data.profession || '', + tone: data.tone || '', + considerations: Array.isArray(data.considerations) ? [...data.considerations] : [] + }; + this.clearPersonalizationFeedback(); + }, + + clearPersonalizationFeedback() { + this.personalizationStatus = ''; + this.personalizationError = ''; + }, + + async savePersonalization() { + if (this.personalizationSaving) { + return; + } + this.personalizationSaving = true; + this.personalizationStatus = ''; + this.personalizationError = ''; + try { + const resp = await fetch('/api/personalization', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(this.personalForm) + }); + const result = await resp.json(); + if (!resp.ok || !result.success) { + throw new Error(result.error || '保存失败'); + } + this.applyPersonalizationData(result.data || {}); + this.personalizationStatus = '已保存'; + setTimeout(() => { + this.personalizationStatus = ''; + }, 3000); + } catch (error) { + this.personalizationError = error.message || '保存失败'; + alert(`保存个性化配置失败:${this.personalizationError}`); + } finally { + this.personalizationSaving = false; + } + }, + + addConsideration() { + if (!this.newConsideration) { + return; + } + if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) { + alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`); + return; + } + this.personalForm.considerations = [ + ...this.personalForm.considerations, + this.newConsideration + ]; + this.newConsideration = ''; + this.clearPersonalizationFeedback(); + }, + + removeConsideration(index) { + const items = [...this.personalForm.considerations]; + items.splice(index, 1); + this.personalForm.considerations = items; + this.clearPersonalizationFeedback(); + }, + + applyTonePreset(preset) { + this.personalForm.tone = preset; + this.clearPersonalizationFeedback(); + }, + + handleConsiderationDragStart(index, event) { + this.draggedConsiderationIndex = index; + if (event && event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + } + }, + + handleConsiderationDragOver(index, event) { + if (event) { + event.preventDefault(); + } + if (this.draggedConsiderationIndex === null || this.draggedConsiderationIndex === index) { + return; + } + const items = [...this.personalForm.considerations]; + const [moved] = items.splice(this.draggedConsiderationIndex, 1); + items.splice(index, 0, moved); + this.personalForm.considerations = items; + this.draggedConsiderationIndex = index; + this.clearPersonalizationFeedback(); + }, + + handleConsiderationDrop(index, event) { + if (event) { + event.preventDefault(); + } + this.draggedConsiderationIndex = null; + }, + + handleConsiderationDragEnd() { + this.draggedConsiderationIndex = null; }, async toggleThinkingMode() { diff --git a/static/index.html b/static/index.html index 789189f..88e08cd 100644 --- a/static/index.html +++ b/static/index.html @@ -812,15 +812,157 @@
+ @mousedown.self="handleOverlayPressStart" + @mouseup.self="handleOverlayPressEnd" + @mouseleave.self="handleOverlayPressCancel" + @touchstart.self.prevent="handleOverlayPressStart" + @touchend.self.prevent="handleOverlayPressEnd" + @touchcancel.self="handleOverlayPressCancel">
-

个人空间

-

敬请期待,个人页面正在建设中。

- +
+
+

个人空间

+

配置 AI 智能体的个性化偏好

+
+
+ + +
+
+
+ +
+
+
+ + + +
+ +
+ 快速填入: +
+ +
+
+
+
+
+
+
+ 您希望AI智能体在回答问题时必须考虑的信息是? +
+ + +
+
    +
  • + + {{ item }} + +
  • +
+

尚未添加任何必备信息

+

最多 {{ personalizationMaxConsiderations }} 条,可拖动排序

+
+
+
+
+ + {{ personalizationStatus }} + + + {{ personalizationError }} + +
+ +
+
+
+
+
+
+ 正在加载个性化配置... +
diff --git a/static/style.css b/static/style.css index 636f9db..3d02a4a 100644 --- a/static/style.css +++ b/static/style.css @@ -2558,40 +2558,56 @@ o-files { justify-content: center; z-index: 400; padding: 20px; + transition: opacity 0.25s ease, backdrop-filter 0.25s ease; + will-change: opacity, backdrop-filter; } .personal-page-card { - width: min(90vw, 420px); + width: min(95vw, 860px); background: #fffaf4; - border-radius: 26px; + border-radius: 24px; border: 1px solid rgba(118, 103, 84, 0.25); box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25); - padding: 40px 48px; - text-align: center; + padding: 40px; + text-align: left; color: var(--claude-text); + max-height: calc(100vh - 40px); + overflow: hidden; } -.personal-page-card h2 { - font-size: 26px; - margin-bottom: 12px; +.personal-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; } -.personal-page-card p { - font-size: 15px; +.personal-page-actions { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.personal-page-header h2 { + font-size: 24px; + margin: 0; +} + +.personal-page-header p { + margin: 6px 0 0; color: var(--claude-text-secondary); - margin-bottom: 32px; + font-size: 14px; } .personal-page-close { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 10px 22px; + align-self: flex-start; + padding: 8px 18px; border-radius: 999px; border: none; background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%); color: #fffdf8; - font-size: 14px; + font-size: 13px; font-weight: 600; cursor: pointer; box-shadow: 0 14px 28px rgba(189, 93, 58, 0.2); @@ -2603,14 +2619,385 @@ o-files { box-shadow: 0 18px 34px rgba(189, 93, 58, 0.3); } +.personal-page-logout { + align-self: flex-start; + padding: 8px 16px; + border-radius: 999px; + border: 1px solid rgba(118, 103, 84, 0.35); + background: rgba(255, 255, 255, 0.92); + color: var(--claude-text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.personal-page-logout:hover { + background: #fff; + box-shadow: 0 12px 24px rgba(38, 28, 18, 0.12); +} + +.personalization-body { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + max-height: calc(100vh - 180px); + padding-right: 6px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.personalization-body::-webkit-scrollbar { + display: none; +} + +.personal-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(118, 103, 84, 0.2); +} + +.toggle-text { + display: flex; + flex-direction: column; + gap: 6px; +} + +.toggle-title { + font-weight: 600; +} + +.toggle-desc { + color: var(--claude-text-secondary); + font-size: 13px; +} + +.toggle-switch { + position: relative; + width: 46px; + height: 26px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch-slider { + position: absolute; + inset: 0; + background-color: #d7d1c5; + border-radius: 30px; + transition: background-color 0.2s ease; +} + +.switch-slider::before { + content: ""; + position: absolute; + left: 4px; + top: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s ease; +} + +.toggle-switch input:checked + .switch-slider { + background: var(--claude-accent); +} + +.toggle-switch input:checked + .switch-slider::before { + transform: translateX(20px); +} + +.personal-form { + display: flex; + flex-direction: column; + gap: 18px; + flex: 1; +} + +.personalization-sections { + display: flex; + gap: 18px; + align-items: flex-start; + flex-wrap: nowrap; +} + +.personal-section { + flex: 1 1 0; + min-width: 240px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(118, 103, 84, 0.25); + border-radius: 18px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.personal-right-column { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1 1 0; + max-width: none; + align-self: stretch; +} + +.personal-right-column .personal-section { + flex: 1 1 auto; +} + +.personal-section.personal-considerations .personal-field { + flex: 1; + display: flex; + flex-direction: column; +} + +.personal-section.personal-considerations .consideration-list { + max-height: 260px; + min-height: 220px; + flex: 1; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.personal-section.personal-considerations .consideration-list::-webkit-scrollbar { + display: none; +} + +.personal-section.personal-considerations .consideration-item { + background: rgba(255, 255, 255, 0.95); +} + +@media (max-width: 1024px) { + .personalization-sections { + flex-direction: column; + flex-wrap: wrap; + } + + .personal-right-column { + width: 100%; + max-width: none; + } +} + +.personal-field { + display: flex; + flex-direction: column; + gap: 10px; + font-size: 14px; +} + +.personal-field input { + width: 100%; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid rgba(118, 103, 84, 0.4); + background: rgba(255, 255, 255, 0.9); + font-size: 14px; +} + +.personal-field input:focus { + outline: 2px solid rgba(189, 93, 58, 0.35); + border-color: rgba(189, 93, 58, 0.6); +} + +.tone-preset-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + font-size: 13px; + color: var(--claude-text-secondary); +} + +.tone-preset-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tone-preset-buttons button { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(118, 103, 84, 0.4); + background: #fff; + font-size: 13px; + cursor: pointer; +} + +.tone-preset-buttons button:hover { + border-color: var(--claude-accent); + color: var(--claude-accent); +} + +.consideration-input { + display: flex; + gap: 8px; + align-items: center; +} + +.consideration-input input { + flex: 1; +} + +.consideration-add { + width: 38px; + height: 38px; + border-radius: 12px; + border: none; + background: var(--claude-accent); + color: #fffdf8; + font-size: 20px; + font-weight: 600; + cursor: pointer; +} + +.consideration-add:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.consideration-list { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.consideration-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px dashed rgba(118, 103, 84, 0.5); + background: rgba(255, 255, 255, 0.9); + cursor: grab; +} + +.drag-handle { + font-size: 16px; + color: var(--claude-text-secondary); +} + +.consideration-text { + flex: 1; + font-size: 14px; + color: var(--claude-text); +} + +.consideration-remove { + border: none; + background: none; + font-size: 18px; + line-height: 1; + cursor: pointer; + color: #d64545; + padding: 0 4px; +} + +.consideration-hint, +.consideration-limit { + font-size: 13px; + color: var(--claude-text-secondary); +} + +.personal-form-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: auto; + padding: 12px 0 0; + justify-content: flex-end; +} + +.personal-status-group { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4px; + min-height: 22px; +} + +.personal-form-actions .primary { + padding: 10px 20px; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.personal-form-actions .primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.personal-form-actions .status { + font-size: 13px; +} + +.personal-form-actions .status.success { + color: #0f9d58; +} + +.personal-form-actions .status.error { + color: #d64545; +} + +.personal-status-fade-enter-active, +.personal-status-fade-leave-active { + transition: opacity 0.25s ease; +} + +.personal-status-fade-enter-from, +.personal-status-fade-leave-to { + opacity: 0; +} + +.personalization-loading { + padding: 32px 0; + text-align: center; + color: var(--claude-text-secondary); + font-size: 14px; +} +} + .personal-page-fade-enter-active, .personal-page-fade-leave-active { - transition: opacity 0.25s ease; + transition: opacity 0.25s ease, backdrop-filter 0.25s ease; } .personal-page-fade-enter-from, .personal-page-fade-leave-to { opacity: 0; + backdrop-filter: blur(0); +} + +.personal-page-overlay.personal-page-fade-enter-active .personal-page-card, +.personal-page-overlay.personal-page-fade-leave-active .personal-page-card { + transition: transform 0.25s ease, opacity 0.25s ease; +} + +.personal-page-overlay.personal-page-fade-enter-from .personal-page-card, +.personal-page-overlay.personal-page-fade-leave-to .personal-page-card { + transform: translateY(18px) scale(0.985); + opacity: 0; } /* 彩蛋灌水特效 */