feat: enhance personal space experience
This commit is contained in:
parent
7c2cc93585
commit
d0af9755c6
174
modules/personalization_manager.py
Normal file
174
modules/personalization_manager.py
Normal file
@ -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
|
||||||
205
static/app.js
205
static/app.js
@ -254,6 +254,24 @@ async function bootstrapApp() {
|
|||||||
currentConversationId: null,
|
currentConversationId: null,
|
||||||
currentConversationTitle: '当前对话',
|
currentConversationTitle: '当前对话',
|
||||||
personalPageVisible: false,
|
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: '',
|
searchQuery: '',
|
||||||
@ -865,11 +883,12 @@ async function bootstrapApp() {
|
|||||||
lastAction.content += data.content;
|
lastAction.content += data.content;
|
||||||
}
|
}
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
if (lastAction && lastAction.blockId) {
|
this.$nextTick(() => {
|
||||||
this.$nextTick(() => this.scrollThinkingToBottom(lastAction.blockId));
|
if (lastAction && lastAction.blockId) {
|
||||||
} else {
|
this.scrollThinkingToBottom(lastAction.blockId);
|
||||||
|
}
|
||||||
this.conditionalScrollToBottom();
|
this.conditionalScrollToBottom();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1606,6 +1625,9 @@ async function bootstrapApp() {
|
|||||||
} else {
|
} else {
|
||||||
this.conversations.push(...data.data.conversations);
|
this.conversations.push(...data.data.conversations);
|
||||||
}
|
}
|
||||||
|
if (this.currentConversationId) {
|
||||||
|
this.promoteConversationToTop(this.currentConversationId);
|
||||||
|
}
|
||||||
this.hasMoreConversations = data.data.has_more;
|
this.hasMoreConversations = data.data.has_more;
|
||||||
console.log(`已加载 ${this.conversations.length} 个对话`);
|
console.log(`已加载 ${this.conversations.length} 个对话`);
|
||||||
|
|
||||||
@ -2143,12 +2165,185 @@ async function bootstrapApp() {
|
|||||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||||
},
|
},
|
||||||
|
|
||||||
openPersonalPage() {
|
async openPersonalPage() {
|
||||||
this.personalPageVisible = true;
|
this.personalPageVisible = true;
|
||||||
|
if (!this.personalizationLoaded && !this.personalizationLoading) {
|
||||||
|
await this.fetchPersonalization();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
closePersonalPage() {
|
closePersonalPage() {
|
||||||
this.personalPageVisible = false;
|
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() {
|
async toggleThinkingMode() {
|
||||||
|
|||||||
@ -812,15 +812,157 @@
|
|||||||
<transition name="personal-page-fade">
|
<transition name="personal-page-fade">
|
||||||
<div class="personal-page-overlay"
|
<div class="personal-page-overlay"
|
||||||
v-if="personalPageVisible"
|
v-if="personalPageVisible"
|
||||||
@click.self="closePersonalPage">
|
@mousedown.self="handleOverlayPressStart"
|
||||||
|
@mouseup.self="handleOverlayPressEnd"
|
||||||
|
@mouseleave.self="handleOverlayPressCancel"
|
||||||
|
@touchstart.self.prevent="handleOverlayPressStart"
|
||||||
|
@touchend.self.prevent="handleOverlayPressEnd"
|
||||||
|
@touchcancel.self="handleOverlayPressCancel">
|
||||||
<div class="personal-page-card">
|
<div class="personal-page-card">
|
||||||
<h2>个人空间</h2>
|
<div class="personal-page-header">
|
||||||
<p>敬请期待,个人页面正在建设中。</p>
|
<div>
|
||||||
<button type="button"
|
<h2>个人空间</h2>
|
||||||
class="personal-page-close"
|
<p>配置 AI 智能体的个性化偏好</p>
|
||||||
@click="closePersonalPage">
|
</div>
|
||||||
返回工作区
|
<div class="personal-page-actions">
|
||||||
</button>
|
<button type="button"
|
||||||
|
class="personal-page-logout"
|
||||||
|
@click="handleLogout">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="personal-page-close"
|
||||||
|
@click="closePersonalPage">
|
||||||
|
返回工作区
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="personalization-body" v-if="!personalizationLoading">
|
||||||
|
<label class="personal-toggle">
|
||||||
|
<span class="toggle-text">
|
||||||
|
<span class="toggle-title">启用个性化提示</span>
|
||||||
|
<span class="toggle-desc">开启后才会注入您的偏好</span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox"
|
||||||
|
v-model="personalForm.enabled"
|
||||||
|
@change="clearPersonalizationFeedback">
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<form class="personal-form" @submit.prevent="savePersonalization">
|
||||||
|
<div class="personalization-sections">
|
||||||
|
<div class="personal-section personal-info">
|
||||||
|
<label class="personal-field">
|
||||||
|
<span>您希望AI智能体怎么自称?</span>
|
||||||
|
<input type="text"
|
||||||
|
v-model.trim="personalForm.self_identify"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="如:小秘、助理小A"
|
||||||
|
@input="clearPersonalizationFeedback">
|
||||||
|
</label>
|
||||||
|
<label class="personal-field">
|
||||||
|
<span>您希望AI智能体怎么称呼您?</span>
|
||||||
|
<input type="text"
|
||||||
|
v-model.trim="personalForm.user_name"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="如:Jojo、老师"
|
||||||
|
@input="clearPersonalizationFeedback">
|
||||||
|
</label>
|
||||||
|
<label class="personal-field">
|
||||||
|
<span>您的职业是?</span>
|
||||||
|
<input type="text"
|
||||||
|
v-model.trim="personalForm.profession"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="如:产品经理、设计师"
|
||||||
|
@input="clearPersonalizationFeedback">
|
||||||
|
</label>
|
||||||
|
<div class="personal-field">
|
||||||
|
<label>
|
||||||
|
<span>您希望AI智能体用何种语气与您交流?</span>
|
||||||
|
<input type="text"
|
||||||
|
v-model.trim="personalForm.tone"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="请选择或输入语气"
|
||||||
|
@input="clearPersonalizationFeedback">
|
||||||
|
</label>
|
||||||
|
<div class="tone-preset-row">
|
||||||
|
<span>快速填入:</span>
|
||||||
|
<div class="tone-preset-buttons">
|
||||||
|
<button type="button"
|
||||||
|
v-for="preset in tonePresets"
|
||||||
|
:key="preset"
|
||||||
|
@click.prevent="applyTonePreset(preset)">
|
||||||
|
{{ preset }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="personal-right-column">
|
||||||
|
<div class="personal-section personal-considerations">
|
||||||
|
<div class="personal-field">
|
||||||
|
<span>您希望AI智能体在回答问题时必须考虑的信息是?</span>
|
||||||
|
<div class="consideration-input">
|
||||||
|
<input type="text"
|
||||||
|
v-model.trim="newConsideration"
|
||||||
|
maxlength="50"
|
||||||
|
placeholder="输入后点击 + 号添加"
|
||||||
|
@input="clearPersonalizationFeedback">
|
||||||
|
<button type="button"
|
||||||
|
class="consideration-add"
|
||||||
|
:disabled="!newConsideration || personalForm.considerations.length >= personalizationMaxConsiderations"
|
||||||
|
@click="addConsideration">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="consideration-list" v-if="personalForm.considerations.length">
|
||||||
|
<li v-for="(item, idx) in personalForm.considerations"
|
||||||
|
:key="`consideration-${idx}`"
|
||||||
|
class="consideration-item"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleConsiderationDragStart(idx, $event)"
|
||||||
|
@dragover.prevent="handleConsiderationDragOver(idx, $event)"
|
||||||
|
@drop.prevent="handleConsiderationDrop(idx, $event)"
|
||||||
|
@dragend="handleConsiderationDragEnd">
|
||||||
|
<span class="drag-handle" aria-hidden="true">≡</span>
|
||||||
|
<span class="consideration-text">{{ item }}</span>
|
||||||
|
<button type="button"
|
||||||
|
class="consideration-remove"
|
||||||
|
@click="removeConsideration(idx)">
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="consideration-hint" v-else>尚未添加任何必备信息</p>
|
||||||
|
<p class="consideration-limit">最多 {{ personalizationMaxConsiderations }} 条,可拖动排序</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="personal-form-actions">
|
||||||
|
<div class="personal-status-group">
|
||||||
|
<transition name="personal-status-fade">
|
||||||
|
<span class="status success"
|
||||||
|
v-if="personalizationStatus">{{ personalizationStatus }}</span>
|
||||||
|
</transition>
|
||||||
|
<transition name="personal-status-fade">
|
||||||
|
<span class="status error"
|
||||||
|
v-if="personalizationError">{{ personalizationError }}</span>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="primary"
|
||||||
|
:disabled="personalizationSaving"
|
||||||
|
@click="savePersonalization">
|
||||||
|
{{ personalizationSaving ? '保存中...' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="personalization-loading" v-else>
|
||||||
|
正在加载个性化配置...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
419
static/style.css
419
static/style.css
@ -2558,40 +2558,56 @@ o-files {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 400;
|
z-index: 400;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
will-change: opacity, backdrop-filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-page-card {
|
.personal-page-card {
|
||||||
width: min(90vw, 420px);
|
width: min(95vw, 860px);
|
||||||
background: #fffaf4;
|
background: #fffaf4;
|
||||||
border-radius: 26px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||||
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
|
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
|
||||||
padding: 40px 48px;
|
padding: 40px;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
color: var(--claude-text);
|
color: var(--claude-text);
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-page-card h2 {
|
.personal-page-header {
|
||||||
font-size: 26px;
|
display: flex;
|
||||||
margin-bottom: 12px;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-page-card p {
|
.personal-page-actions {
|
||||||
font-size: 15px;
|
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);
|
color: var(--claude-text-secondary);
|
||||||
margin-bottom: 32px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-page-close {
|
.personal-page-close {
|
||||||
display: inline-flex;
|
align-self: flex-start;
|
||||||
align-items: center;
|
padding: 8px 18px;
|
||||||
justify-content: center;
|
|
||||||
padding: 10px 22px;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||||
color: #fffdf8;
|
color: #fffdf8;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 14px 28px rgba(189, 93, 58, 0.2);
|
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);
|
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-enter-active,
|
||||||
.personal-page-fade-leave-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-enter-from,
|
||||||
.personal-page-fade-leave-to {
|
.personal-page-fade-leave-to {
|
||||||
opacity: 0;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 彩蛋灌水特效 */
|
/* 彩蛋灌水特效 */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user