一个便于用户自定义、保存并同步 Qwen Chat 自定义角色的 Tampermonkey 脚本。A Tampermonkey script for customizing user-defined personas in Qwen Chat.
// ==UserScript==
// @name QwenPersona
// @namespace https://www.kev1nweng.space
// @version 1764268716
// @description 一个便于用户自定义、保存并同步 Qwen Chat 自定义角色的 Tampermonkey 脚本。A Tampermonkey script for customizing user-defined personas in Qwen Chat.
// @author 小翁同学 (kev1nweng)
// @license AGPL-3.0
// @match https://chat.qwen.ai/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
// ==================== Constants & Configuration ====================
const CONSTANTS = {
STORAGE: {
PERSONAS: "qwen_personas",
SELECTED: "qwen_selected_persona",
MODELS_CACHE: "qwen_models_cache",
CHAT_MAP: "qwen_chat_persona_map",
},
SELECTORS: {
// Custom UI IDs
CONTAINER: "persona-dropdown-container",
TRIGGER: "persona-trigger",
MENU: "persona-dropdown-menu",
MODAL_OVERLAY: "persona-modal-overlay",
MODAL: "persona-modal",
CREATE_BTN: "persona-create-btn",
IMPORT_BTN: "persona-import-btn",
SAVE_BTN: "persona-save-btn",
STYLE_ID: "persona-manager-styles",
// Form Inputs
INPUT_NAME: "persona-name-input",
INPUT_EMOJI: "persona-emoji-input",
INPUT_MODEL: "persona-model-input",
INPUT_PROMPT: "persona-prompt-input",
INPUT_DEEP_THINKING: "persona-deep-thinking-input",
INPUT_WEB_SEARCH: "persona-web-search-input",
// Qwen UI Elements
HEADER_DESKTOP: ".header-desktop",
HEADER_LEFT: ".header-desktop .header-content .header-left",
MODEL_SELECTOR: '[class*="index-module__web-model-selector"]',
MODEL_SELECTOR_CONTENT: '[class*="index-module__model-selector-content"]',
MODEL_SELECTOR_DROPDOWN:
'[class*="index-module__model-selector-dropdown"]',
MODEL_SELECTOR_ITEM: '[class*="index-module__model-selector-item"]',
MODEL_NAME_TEXT: '[class*="index-module__model-name-text"]',
MODEL_SELECTOR_VIEW_MORE:
'[class*="index-module__model-selector-view-more"]',
MODEL_VIEW_MORE_TEXT: '[class*="index-module__view-more-text"]',
ANT_DROPDOWN_TRIGGER: ".ant-dropdown-trigger",
CHAT_INPUT_FEATURE_BTN: ".chat-input-feature-btn",
CHAT_INPUT_FEATURE_TEXT: ".chat-input-feature-btn-text",
WEB_SEARCH_BTN: "button.websearch_button",
TEXTAREA: "textarea",
INPUT_CONTAINER: ".chat-message-input-container-inner",
// Classes
TRIGGER_COLLAPSED: "collapsed",
TRIGGER_ACTIVE: "active",
MENU_VISIBLE: "visible",
ARROW_OPEN: "open",
ITEM_SELECTED: "selected",
INPUT_DISABLED: "persona-input-disabled",
INTERACTION_DISABLED: "persona-interaction-disabled",
TRANSITION: "persona-input-transition",
AGENT_ACTIVE: "persona-agent-active",
},
};
// ==================== State Management ====================
const State = {
personas: [],
selectedPersonaId: null,
availableModels: [],
dropdownVisible: false,
modalVisible: false,
editingPersona: null,
lastUrl: location.href,
chatPersonaMap: {},
};
// ==================== I18n Service ====================
const I18n = {
locale: "en",
translations: {
en: {
noPersona: "No Persona",
selectPersona: "Select Persona",
useDefaultSettings: "Use default settings",
defaultModel: "Default Model",
edit: "Edit",
delete: "Delete",
createPersona: "Create new Persona...",
personaName: "Persona Name",
namePlaceholder: "e.g. Coding Assistant, Translator...",
icon: "Icon",
model: "Model",
useCurrentModel: "Use current model",
modelHint:
"Select the model for this Persona. Leave empty to use the current model.",
systemPrompt: "System Prompt",
promptPlaceholder:
"Enter custom system prompt to define AI behavior and role...",
promptHint:
"This prompt will be injected as a system message, overriding default prompts.",
features: "Features",
deepThinking: "Deep Thinking",
webSearch: "Web Search",
featuresHint:
"Enabled features will be automatically activated for each chat.",
cancel: "Cancel",
save: "Save",
deleteConfirm: "Are you sure you want to delete this Persona?",
editPersona: "Edit Persona",
createNewPersona: "Create New Persona",
importFromUrl: "Import from URL",
enterUrl: "Enter the URL of the persona configuration JSON:",
importSuccess: "Personas imported successfully!",
importError:
"Failed to import personas. Please check the URL and JSON format.",
},
zh: {
noPersona: "无 Persona",
selectPersona: "选择 Persona",
useDefaultSettings: "使用默认设置",
defaultModel: "默认模型",
edit: "编辑",
delete: "删除",
createPersona: "创建新 Persona...",
personaName: "Persona 名称",
namePlaceholder: "例如:代码助手、翻译专家...",
icon: "图标",
model: "模型",
useCurrentModel: "使用当前选择的模型",
modelHint: "选择此 Persona 使用的模型,留空则使用页面当前选择的模型",
systemPrompt: "系统提示词",
promptPlaceholder: "输入自定义系统提示词,定义 AI 的行为和角色...",
promptHint: "此提示词将作为系统消息注入到对话中,优先于默认系统提示",
features: "增强功能",
deepThinking: "深度思考",
webSearch: "联网搜索",
featuresHint: "启用后将在每次对话时自动开启对应功能",
cancel: "取消",
save: "保存",
deleteConfirm: "确定要删除这个 Persona 吗?",
editPersona: "编辑 Persona",
createNewPersona: "创建新 Persona",
importFromUrl: "从 URL 导入",
enterUrl: "请输入 Persona 配置 JSON 的 URL:",
importSuccess: "Persona 导入成功!",
importError: "导入失败,请检查 URL 和 JSON 格式。",
},
},
init() {
const lang = navigator.language || navigator.userLanguage;
this.locale = lang.startsWith("zh") ? "zh" : "en";
},
t(key) {
return (
this.translations[this.locale]?.[key] ||
this.translations["en"][key] ||
key
);
},
};
// ==================== Utilities ====================
const Utils = {
escapeHtml(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
waitForElement(selector, timeout = 2000) {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
},
};
// ==================== Storage Service ====================
const Storage = {
loadPersonas() {
try {
const stored = localStorage.getItem(CONSTANTS.STORAGE.PERSONAS);
State.personas = stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("[QwenPersona] Failed to load personas:", e);
State.personas = [];
}
},
savePersonas() {
try {
localStorage.setItem(
CONSTANTS.STORAGE.PERSONAS,
JSON.stringify(State.personas)
);
} catch (e) {
console.error("[QwenPersona] Failed to save personas:", e);
}
},
loadSelectedPersona() {
try {
State.selectedPersonaId =
localStorage.getItem(CONSTANTS.STORAGE.SELECTED) || null;
} catch (e) {
State.selectedPersonaId = null;
}
},
saveSelectedPersona() {
try {
if (State.selectedPersonaId) {
localStorage.setItem(
CONSTANTS.STORAGE.SELECTED,
State.selectedPersonaId
);
} else {
localStorage.removeItem(CONSTANTS.STORAGE.SELECTED);
}
} catch (e) {
console.error("[QwenPersona] Failed to save selected persona:", e);
}
},
loadChatPersonaMap() {
try {
const stored = localStorage.getItem(CONSTANTS.STORAGE.CHAT_MAP);
State.chatPersonaMap = stored ? JSON.parse(stored) : {};
} catch (e) {
State.chatPersonaMap = {};
}
},
saveChatPersonaMap() {
try {
localStorage.setItem(
CONSTANTS.STORAGE.CHAT_MAP,
JSON.stringify(State.chatPersonaMap)
);
} catch (e) {
console.error("[QwenPersona] Failed to save chat persona map:", e);
}
},
};
// ==================== UI Service ====================
const UI = {
injectStyles() {
const style = document.createElement("style");
style.id = CONSTANTS.SELECTORS.STYLE_ID;
style.textContent = `
/* Persona Dropdown Container */
.persona-dropdown-container {
position: relative;
display: flex;
align-items: center;
margin-left: 6px;
z-index: 100;
}
/* Transition Helper */
.persona-input-transition {
transition: filter 0.3s ease, opacity 0.3s ease;
}
/* Disabled Input State */
.persona-input-disabled {
filter: grayscale(100%);
opacity: 0.7;
pointer-events: none;
cursor: not-allowed;
position: relative;
}
.persona-input-disabled::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
border-radius: inherit;
}
/* Persona Trigger Button */
.persona-trigger {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: var(--persona-trigger-expanded-width, 10rem);
min-width: 36px;
height: 36px;
padding: 0 12px;
box-sizing: border-box;
border-radius: 999px;
background: var(--container-secondary-fill, #f7f8fc);
cursor: pointer;
border: 1px solid var(--line-secondary-border, #e0e2eb);
font-size: 14px;
color: var(--character-primary-text, #2c2c36);
overflow: hidden;
transition:
width 0.5s cubic-bezier(0.22, 1, 0.36, 1),
padding 0.5s cubic-bezier(0.22, 1, 0.36, 1),
gap 0.5s cubic-bezier(0.22, 1, 0.36, 1),
background 0.2s ease,
border 0.2s ease,
color 0.2s ease,
filter 0.3s ease,
opacity 0.3s ease;
}
/* Collapsed State (Circular) */
.persona-trigger.collapsed {
width: 36px;
padding: 0;
justify-content: center;
background: transparent;
gap: 0;
margin-right: 4px;
}
.persona-trigger.collapsed:hover {
background: var(--container-secondary-fill, #f7f8fc);
}
.persona-trigger.collapsed .persona-trigger-text,
.persona-trigger.collapsed .persona-trigger-arrow {
opacity: 0;
width: 0;
margin: 0;
pointer-events: none;
}
.persona-trigger.collapsed .persona-trigger-icon {
margin: 0;
font-size: 20px;
width: 100%;
height: 100%;
justify-content: center;
}
.persona-trigger:hover {
background: var(--container-tertiary-fill, #eef0f5);
}
.persona-trigger.active {
border-color: var(--btn-brandprimary-fill, #615ced);
background: var(--container-brandquinary-fill, #eeedff);
}
.persona-trigger-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.persona-trigger-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
transition: opacity 0.2s ease, width 0.2s ease;
}
.persona-trigger-arrow {
font-size: 16px;
transition: transform 0.2s ease, opacity 0.2s ease, width 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center;
}
.persona-trigger-arrow.open {
transform: rotate(180deg);
}
/* Dropdown Menu */
.persona-dropdown-menu {
position: fixed;
min-width: 240px;
max-width: 320px;
background: var(--container-primary-fill, #fff);
border-radius: 16px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border: 1px solid var(--line-secondary-border, #e0e2eb);
z-index: 99999;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
overflow: visible;
}
.persona-dropdown-menu.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.persona-dropdown-header {
padding: 12px 16px;
font-size: 12px;
font-weight: 500;
color: var(--character-tertiary-text, #8f91a8);
border-bottom: 1px solid var(--line-secondary-border, #e0e2eb);
}
.persona-dropdown-list {
max-height: 66vh;
overflow-y: auto;
padding: 0 8px;
}
.persona-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer !important;
border-radius: 12px;
transition: all 0.15s ease;
margin: 8px 0;
user-select: none;
}
.persona-dropdown-item:hover {
background: var(--container-secondary-fill, #f7f8fc) !important;
}
.persona-dropdown-item.selected {
background: var(--container-brandquinary-fill, #eeedff);
}
.persona-dropdown-item.selected:hover {
background: var(--container-brandquinary-fill, #eeedff) !important;
}
.persona-dropdown-item-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--container-tertiary-fill, #eef0f5);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.persona-dropdown-item.selected .persona-dropdown-item-icon {
background: var(--btn-brandprimary-fill, #615ced);
color: white;
}
.persona-dropdown-item-content {
flex: 1;
overflow: hidden;
}
.persona-dropdown-item-name {
font-size: 14px;
font-weight: 500;
color: var(--character-primary-text, #2c2c36);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.persona-dropdown-item-model {
font-size: 12px;
color: var(--character-tertiary-text, #8f91a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.persona-dropdown-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-dropdown-item:hover .persona-dropdown-item-actions {
opacity: 1;
}
.persona-action-btn {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
color: var(--character-tertiary-text, #8f91a8);
}
.persona-action-btn:hover {
background: var(--container-tertiary-fill, #eef0f5);
color: var(--character-primary-text, #2c2c36);
}
.persona-action-btn.delete:hover {
background: #fee2e2;
color: #dc2626;
}
.persona-dropdown-divider {
height: 1px;
background: var(--line-secondary-border, #e0e2eb);
}
.persona-dropdown-footer {
padding: 4px;
}
.persona-create-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer !important;
border-radius: 12px;
transition: all 0.15s ease;
color: var(--btn-brandprimary-fill, #615ced);
font-size: 14px;
font-weight: 500;
user-select: none;
}
.persona-create-btn:hover {
background: var(--container-brandquinary-fill, #eeedff) !important;
}
/* Modal Styles */
.persona-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.persona-modal-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.persona-modal {
background: var(--container-primary-fill, #fff);
border-radius: 20px;
width: 480px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
transform: scale(0.95);
transition: transform 0.2s ease;
}
.persona-modal-overlay.visible .persona-modal {
transform: scale(1);
}
.persona-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--line-secondary-border, #e0e2eb);
}
.persona-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--character-primary-text, #2c2c36);
}
.persona-modal-close {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
color: var(--character-tertiary-text, #8f91a8);
}
.persona-modal-close:hover {
background: var(--container-secondary-fill, #f7f8fc);
}
.persona-modal-body {
padding: 24px;
overflow-y: auto;
max-height: calc(90vh - 180px);
}
.persona-form-group {
margin-bottom: 20px;
}
.persona-form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--character-primary-text, #2c2c36);
margin-bottom: 8px;
}
.persona-form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--line-secondary-border, #e0e2eb);
border-radius: 12px;
font-size: 14px;
color: var(--character-primary-text, #2c2c36);
background: var(--container-primary-fill, #fff);
transition: all 0.15s ease;
box-sizing: border-box;
}
.persona-form-input:focus {
outline: none;
border-color: var(--btn-brandprimary-fill, #615ced);
box-shadow: 0 0 0 3px rgba(97, 92, 237, 0.1);
}
.persona-form-textarea {
resize: vertical;
min-height: 120px;
font-family: inherit;
line-height: 1.5;
}
.persona-form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238f91a8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
}
.persona-form-hint {
font-size: 12px;
color: var(--character-tertiary-text, #8f91a8);
margin-top: 6px;
}
.persona-modal-footer {
display: flex;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--line-secondary-border, #e0e2eb);
}
.persona-btn {
flex: 1;
padding: 12px 24px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.persona-btn-secondary {
background: var(--container-secondary-fill, #f7f8fc);
color: var(--character-primary-text, #2c2c36);
}
.persona-btn-secondary:hover {
background: var(--container-tertiary-fill, #eef0f5);
}
.persona-btn-primary {
background: var(--btn-brandprimary-fill, #615ced);
color: white;
}
.persona-btn-primary:hover {
background: #5248d9;
}
.persona-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dark mode adjustments */
html.dark .persona-trigger {
background: var(--container-secondary-fill, #2a2a2a);
}
html.dark .persona-trigger:hover {
background: var(--container-tertiary-fill, #3a3a3a);
}
html.dark .persona-dropdown-menu {
background: var(--container-primary-fill, #1f1f1f);
border-color: var(--line-secondary-border, #424554);
}
html.dark .persona-modal {
background: var(--container-primary-fill, #1f1f1f);
}
html.dark .persona-form-input {
background: var(--container-secondary-fill, #2a2a2a);
border-color: var(--line-secondary-border, #424554);
}
/* Animation */
@keyframes persona-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.persona-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--btn-brandprimary-fill, #615ced);
animation: persona-pulse 2s ease-in-out infinite;
}
/* Scrollbar styling */
.persona-dropdown-list::-webkit-scrollbar {
width: 6px;
}
.persona-dropdown-list::-webkit-scrollbar-track {
background: transparent;
}
.persona-dropdown-list::-webkit-scrollbar-thumb {
background: var(--line-secondary-border, #e0e2eb);
border-radius: 3px;
}
.persona-dropdown-list::-webkit-scrollbar-thumb:hover {
background: var(--character-quaternary-text, #c8cad9);
}
/* Checkbox group styling */
.persona-form-checkbox-group {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.persona-form-checkbox-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.persona-form-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--line-secondary-border, #e0e2eb);
border-radius: 4px;
appearance: none;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
background: var(--container-primary-fill, #fff);
}
.persona-form-checkbox:checked {
background: var(--btn-brandprimary-fill, #615ced);
border-color: var(--btn-brandprimary-fill, #615ced);
}
.persona-form-checkbox:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.persona-form-checkbox:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(97, 92, 237, 0.1);
}
.persona-form-checkbox-label {
font-size: 14px;
color: var(--character-primary-text, #2c2c36);
}
/* Feature badges in dropdown */
.persona-dropdown-item-features {
display: flex;
gap: 4px;
margin-top: 2px;
}
.persona-feature-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--container-tertiary-fill, #eef0f5);
color: var(--character-tertiary-text, #8f91a8);
}
.persona-feature-badge.active {
background: var(--container-brandquinary-fill, #eeedff);
color: var(--btn-brandprimary-fill, #615ced);
}
/* Adjust model selector margin */
[class*="index-module__web-model-selector"] {
margin-left: 0;
}
/* Hide native model selector when agent is active */
body.persona-agent-active [class*="index-module__web-model-selector"] {
display: none !important;
}
/* Hide the "设为默认/取消默认" button when agent is active */
/* This might need adjustment based on new structure, but usually it's near the selector */
body.persona-agent-active [class*="index-module__add-model-icon"] {
display: none !important;
}
/* Adjust navbar layout when model selector is hidden */
body.persona-agent-active .persona-dropdown-container {
margin-left: 0;
margin-right: 16px;
}
/* Emoji Picker Styles */
.persona-emoji-wrapper {
position: relative;
}
.persona-emoji-trigger {
font-size: 24px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-secondary-border, #e0e2eb);
border-radius: 12px;
cursor: pointer;
background: var(--container-primary-fill, #fff);
transition: all 0.15s ease;
}
.persona-emoji-trigger:hover {
background: var(--container-secondary-fill, #f7f8fc);
border-color: var(--btn-brandprimary-fill, #615ced);
}
.persona-emoji-picker-container {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
margin-top: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
border: 1px solid var(--line-secondary-border, #e0e2eb);
border-radius: 12px;
overflow: hidden;
display: none;
width: 380px;
max-width: 90vw;
}
.persona-emoji-picker-container.visible {
display: block;
}
emoji-picker {
--background: var(--container-primary-fill, #fff);
--border-color: var(--line-secondary-border, #e0e2eb);
--input-border-color: var(--line-secondary-border, #e0e2eb);
--input-font-color: var(--character-primary-text, #2c2c36);
--button-hover-background: var(--container-secondary-fill, #f7f8fc);
width: 100%;
height: 320px;
}
html.dark emoji-picker {
--background: #1f1f1f;
--border-color: #424554;
--input-border-color: #424554;
--input-font-color: #e0e2eb;
--button-hover-background: #2a2a2a;
}
`;
document.head.appendChild(style);
},
createDropdownUI() {
const container = document.createElement("div");
container.className = "persona-dropdown-container";
container.id = CONSTANTS.SELECTORS.CONTAINER;
const trigger = document.createElement("div");
trigger.className = `persona-trigger ${CONSTANTS.SELECTORS.TRIGGER_COLLAPSED}`;
trigger.id = CONSTANTS.SELECTORS.TRIGGER;
trigger.innerHTML = `
<span class="persona-trigger-icon">🤖</span>
<span class="persona-trigger-text">${I18n.t("noPersona")}</span>
<span class="persona-trigger-arrow">
<svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><use xlink:href="#icon-line-chevron-down"></use></svg>
</span>
`;
trigger.onclick = (e) => {
e.stopPropagation();
UI.toggleDropdown();
};
trigger.addEventListener("mouseenter", () => {
trigger.classList.remove(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
});
trigger.addEventListener("mouseleave", () => {
if (!State.selectedPersonaId && !State.dropdownVisible) {
trigger.classList.add(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
}
});
container.appendChild(trigger);
if (!document.getElementById(CONSTANTS.SELECTORS.MENU)) {
const menu = document.createElement("div");
menu.className = "persona-dropdown-menu";
menu.id = CONSTANTS.SELECTORS.MENU;
document.body.appendChild(menu);
}
return container;
},
renderDropdownMenu() {
const menu = document.getElementById(CONSTANTS.SELECTORS.MENU);
if (!menu) return;
let html = `
<div class="persona-dropdown-header">${I18n.t(
"selectPersona"
)}</div>
<div class="persona-dropdown-list">
<div class="persona-dropdown-item ${
!State.selectedPersonaId
? CONSTANTS.SELECTORS.ITEM_SELECTED
: ""
}" data-id="">
<div class="persona-dropdown-item-icon">🚫</div>
<div class="persona-dropdown-item-content">
<div class="persona-dropdown-item-name">${I18n.t(
"noPersona"
)}</div>
<div class="persona-dropdown-item-model">${I18n.t(
"useDefaultSettings"
)}</div>
</div>
</div>
`;
State.personas.forEach((persona) => {
let featureBadges = "";
if (persona.deepThinking || persona.webSearch) {
featureBadges = '<div class="persona-dropdown-item-features">';
if (persona.deepThinking) {
featureBadges += `<span class="persona-feature-badge active">${I18n.t(
"deepThinking"
)}</span>`;
}
if (persona.webSearch) {
featureBadges += `<span class="persona-feature-badge active">${I18n.t(
"webSearch"
)}</span>`;
}
featureBadges += "</div>";
}
html += `
<div class="persona-dropdown-item ${
persona.id === State.selectedPersonaId
? CONSTANTS.SELECTORS.ITEM_SELECTED
: ""
}" data-id="${persona.id}">
<div class="persona-dropdown-item-icon">${
persona.emoji || "🚫"
}</div>
<div class="persona-dropdown-item-content">
<div class="persona-dropdown-item-name">${Utils.escapeHtml(
persona.name
)}</div>
<div class="persona-dropdown-item-model">${Utils.escapeHtml(
persona.modelName ||
persona.model ||
I18n.t("defaultModel")
)}</div>
${featureBadges}
</div>
<div class="persona-dropdown-item-actions">
<div class="persona-action-btn edit" data-action="edit" data-id="${
persona.id
}" title="${I18n.t("edit")}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</div>
<div class="persona-action-btn delete" data-action="delete" data-id="${
persona.id
}" title="${I18n.t("delete")}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</div>
</div>
</div>
`;
});
html += `
</div>
<div class="persona-dropdown-divider"></div>
<div class="persona-dropdown-footer">
<div class="persona-create-btn" id="${
CONSTANTS.SELECTORS.CREATE_BTN
}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>${I18n.t("createPersona")}</span>
</div>
<div class="persona-create-btn" id="${
CONSTANTS.SELECTORS.IMPORT_BTN
}" style="margin-top: 4px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>${I18n.t("importFromUrl")}</span>
</div>
</div>
`;
menu.innerHTML = html;
menu.querySelectorAll(".persona-dropdown-item").forEach((item) => {
item.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (e.target.closest(".persona-action-btn")) return;
const id = item.dataset.id;
console.log("[QwenPersona] Item clicked, id:", id);
PersonaManager.selectPersona(id || null);
});
});
menu.querySelectorAll(".persona-action-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const action = btn.dataset.action;
const id = btn.dataset.id;
console.log("[QwenPersona] Action clicked:", action, id);
if (action === "edit") {
PersonaManager.editPersona(id);
} else if (action === "delete") {
PersonaManager.deletePersona(id);
}
});
});
const createBtn = document.getElementById(CONSTANTS.SELECTORS.CREATE_BTN);
if (createBtn) {
createBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
console.log("[QwenPersona] Create button clicked");
UI.openModal();
});
}
const importBtn = document.getElementById(CONSTANTS.SELECTORS.IMPORT_BTN);
if (importBtn) {
importBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
console.log("[QwenPersona] Import button clicked");
const url = prompt(I18n.t("enterUrl"));
if (url) {
PersonaManager.importFromUrl(url);
}
});
}
},
updateTriggerUI() {
const trigger = document.getElementById(CONSTANTS.SELECTORS.TRIGGER);
if (!trigger) return;
const selectedPersona = State.personas.find(
(p) => p.id === State.selectedPersonaId
);
const iconSpan = trigger.querySelector(".persona-trigger-icon");
const textSpan = trigger.querySelector(".persona-trigger-text");
if (selectedPersona) {
trigger.classList.remove(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
trigger.classList.add(CONSTANTS.SELECTORS.TRIGGER_ACTIVE);
iconSpan.textContent = selectedPersona.emoji || "🚫";
textSpan.textContent = selectedPersona.name;
if (selectedPersona.model) {
document.body.classList.add(CONSTANTS.SELECTORS.AGENT_ACTIVE);
} else {
document.body.classList.remove(CONSTANTS.SELECTORS.AGENT_ACTIVE);
}
} else {
trigger.classList.add(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
trigger.classList.remove(CONSTANTS.SELECTORS.TRIGGER_ACTIVE);
iconSpan.textContent = "🤖";
textSpan.textContent = I18n.t("noPersona");
document.body.classList.remove(CONSTANTS.SELECTORS.AGENT_ACTIVE);
}
},
createModalUI() {
const overlay = document.createElement("div");
overlay.className = "persona-modal-overlay";
overlay.id = CONSTANTS.SELECTORS.MODAL_OVERLAY;
overlay.onclick = (e) => {
if (e.target === overlay) UI.closeModal();
};
const modal = document.createElement("div");
modal.className = "persona-modal";
modal.id = CONSTANTS.SELECTORS.MODAL;
overlay.appendChild(modal);
document.body.appendChild(overlay);
},
renderModal(persona = null) {
const modal = document.getElementById(CONSTANTS.SELECTORS.MODAL);
if (!modal) return;
const isEdit = !!persona;
const title = isEdit ? I18n.t("editPersona") : I18n.t("createNewPersona");
let modelOptions = "";
State.availableModels.forEach((m) => {
const selected = persona && persona.model === m.id ? "selected" : "";
modelOptions += `<option value="${m.id}" ${selected}>${
m.name || m.id
}</option>`;
});
modal.innerHTML = `
<div class="persona-modal-header">
<div class="persona-modal-title">${title}</div>
<div class="persona-modal-close" onclick="window.personaManager.closeModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</div>
<div class="persona-modal-body">
<div class="persona-form-group">
<label class="persona-form-label">${I18n.t(
"personaName"
)}</label>
<input type="text" class="persona-form-input" id="${
CONSTANTS.SELECTORS.INPUT_NAME
}"
value="${
persona ? Utils.escapeHtml(persona.name) : ""
}"
placeholder="${I18n.t("namePlaceholder")}">
</div>
<div class="persona-form-group">
<label class="persona-form-label">${I18n.t("icon")}</label>
<div class="persona-emoji-wrapper">
<button class="persona-emoji-trigger" id="persona-emoji-trigger-btn">
${persona ? persona.emoji : "🤖"}
</button>
<input type="hidden" id="${
CONSTANTS.SELECTORS.INPUT_EMOJI
}" value="${persona ? persona.emoji : "🤖"}">
<div class="persona-emoji-picker-container" id="persona-emoji-picker">
<emoji-picker></emoji-picker>
</div>
</div>
</div>
<div class="persona-form-group">
<label class="persona-form-label">${I18n.t("model")}</label>
<select class="persona-form-input persona-form-select" id="${
CONSTANTS.SELECTORS.INPUT_MODEL
}">
<option value="">${I18n.t("useCurrentModel")}</option>
${modelOptions}
</select>
<div class="persona-form-hint">${I18n.t("modelHint")}</div>
</div>
<div class="persona-form-group">
<label class="persona-form-label">${I18n.t(
"systemPrompt"
)}</label>
<textarea class="persona-form-input persona-form-textarea" id="${
CONSTANTS.SELECTORS.INPUT_PROMPT
}"
placeholder="${I18n.t("promptPlaceholder")}">${
persona ? Utils.escapeHtml(persona.prompt) : ""
}</textarea>
<div class="persona-form-hint">${I18n.t("promptHint")}</div>
</div>
<div class="persona-form-group">
<label class="persona-form-label">${I18n.t(
"features"
)}</label>
<div class="persona-form-checkbox-group">
<label class="persona-form-checkbox-item">
<input type="checkbox" class="persona-form-checkbox" id="${
CONSTANTS.SELECTORS.INPUT_DEEP_THINKING
}"
${
persona && persona.deepThinking
? "checked"
: ""
}>
<span class="persona-form-checkbox-label">${I18n.t(
"deepThinking"
)}</span>
</label>
<label class="persona-form-checkbox-item">
<input type="checkbox" class="persona-form-checkbox" id="${
CONSTANTS.SELECTORS.INPUT_WEB_SEARCH
}"
${
persona && persona.webSearch
? "checked"
: ""
}>
<span class="persona-form-checkbox-label">${I18n.t(
"webSearch"
)}</span>
</label>
</div>
<div class="persona-form-hint">${I18n.t(
"featuresHint"
)}</div>
</div>
</div>
<div class="persona-modal-footer">
<button class="persona-btn persona-btn-secondary" onclick="window.personaManager.closeModal()">${I18n.t(
"cancel"
)}</button>
<button class="persona-btn persona-btn-primary" id="${
CONSTANTS.SELECTORS.SAVE_BTN
}">${I18n.t("save")}</button>
</div>
`;
// Emoji Picker Logic
const emojiBtn = document.getElementById("persona-emoji-trigger-btn");
const emojiInput = document.getElementById(
CONSTANTS.SELECTORS.INPUT_EMOJI
);
const emojiPickerContainer = document.getElementById(
"persona-emoji-picker"
);
const emojiPicker = emojiPickerContainer
? emojiPickerContainer.querySelector("emoji-picker")
: null;
if (emojiBtn && emojiPickerContainer) {
emojiBtn.onclick = (e) => {
e.stopPropagation();
emojiPickerContainer.classList.toggle("visible");
};
}
if (emojiPicker) {
if (I18n.locale === "zh") {
emojiPicker.dataSource =
"https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/zh/cldr/data.json";
emojiPicker.locale = "zh";
}
emojiPicker.addEventListener("emoji-click", (event) => {
const emoji = event.detail.unicode;
emojiBtn.textContent = emoji;
emojiInput.value = emoji;
emojiPickerContainer.classList.remove("visible");
});
}
document.getElementById(CONSTANTS.SELECTORS.SAVE_BTN).onclick = () =>
PersonaManager.savePersonaFromModal(persona?.id);
},
toggleDropdown() {
State.dropdownVisible = !State.dropdownVisible;
const trigger = document.getElementById(CONSTANTS.SELECTORS.TRIGGER);
const menu = document.getElementById(CONSTANTS.SELECTORS.MENU);
const arrow = document.querySelector(".persona-trigger-arrow");
if (State.dropdownVisible) {
trigger.classList.remove(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
UI.renderDropdownMenu();
if (trigger && menu) {
const rect = trigger.getBoundingClientRect();
menu.style.top = rect.bottom + 8 + "px";
menu.style.left = rect.left + "px";
console.log("[QwenPersona] Dropdown position:", {
top: menu.style.top,
left: menu.style.left,
rect,
});
} else {
console.warn("[QwenPersona] Missing elements:", {
trigger: !!trigger,
menu: !!menu,
});
}
menu.classList.add(CONSTANTS.SELECTORS.MENU_VISIBLE);
if (arrow) arrow.classList.add(CONSTANTS.SELECTORS.ARROW_OPEN);
console.log(
"[QwenPersona] Dropdown opened, menu classes:",
menu.className
);
} else {
if (!State.selectedPersonaId) {
trigger.classList.add(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
}
menu.classList.remove(CONSTANTS.SELECTORS.MENU_VISIBLE);
if (arrow) arrow.classList.remove(CONSTANTS.SELECTORS.ARROW_OPEN);
console.log("[QwenPersona] Dropdown closed");
}
},
closeDropdown() {
State.dropdownVisible = false;
const trigger = document.getElementById(CONSTANTS.SELECTORS.TRIGGER);
const menu = document.getElementById(CONSTANTS.SELECTORS.MENU);
const arrow = document.querySelector(".persona-trigger-arrow");
if (trigger && !State.selectedPersonaId) {
trigger.classList.add(CONSTANTS.SELECTORS.TRIGGER_COLLAPSED);
}
if (menu) menu.classList.remove(CONSTANTS.SELECTORS.MENU_VISIBLE);
if (arrow) arrow.classList.remove(CONSTANTS.SELECTORS.ARROW_OPEN);
},
openModal(persona = null) {
State.editingPersona = persona;
UI.closeDropdown();
UI.renderModal(persona);
const overlay = document.getElementById(
CONSTANTS.SELECTORS.MODAL_OVERLAY
);
if (overlay) {
overlay.classList.add(CONSTANTS.SELECTORS.MENU_VISIBLE);
State.modalVisible = true;
}
},
closeModal() {
const overlay = document.getElementById(
CONSTANTS.SELECTORS.MODAL_OVERLAY
);
if (overlay) {
overlay.classList.remove(CONSTANTS.SELECTORS.MENU_VISIBLE);
State.modalVisible = false;
}
State.editingPersona = null;
},
setInteractionState(disabled) {
const personaTrigger = document.getElementById(
CONSTANTS.SELECTORS.TRIGGER
);
if (personaTrigger) {
if (disabled) {
personaTrigger.classList.add(CONSTANTS.SELECTORS.INPUT_DISABLED);
} else {
personaTrigger.classList.remove(CONSTANTS.SELECTORS.INPUT_DISABLED);
}
}
const textareas = document.querySelectorAll(CONSTANTS.SELECTORS.TEXTAREA);
textareas.forEach((t) => {
const container =
t.closest(CONSTANTS.SELECTORS.INPUT_CONTAINER) || t.parentElement;
if (container) {
container.classList.add(CONSTANTS.SELECTORS.TRANSITION);
}
if (disabled) {
if (!t.disabled) {
t.dataset.originalPlaceholder = t.placeholder || "";
t.placeholder = "正在切换 Persona...";
t.disabled = true;
t.classList.add(CONSTANTS.SELECTORS.INTERACTION_DISABLED);
if (container) {
container.classList.add(CONSTANTS.SELECTORS.INPUT_DISABLED);
}
}
} else {
if (
t.disabled &&
t.classList.contains(CONSTANTS.SELECTORS.INTERACTION_DISABLED)
) {
t.disabled = false;
t.placeholder = t.dataset.originalPlaceholder || "";
t.classList.remove(CONSTANTS.SELECTORS.INTERACTION_DISABLED);
if (container) {
container.classList.remove(CONSTANTS.SELECTORS.INPUT_DISABLED);
}
}
}
});
},
waitForNavbar() {
const maxAttempts = 50;
let attempts = 0;
const checkNavbar = () => {
const headerLeft = document.querySelector(
CONSTANTS.SELECTORS.HEADER_LEFT
);
if (
headerLeft &&
!document.getElementById(CONSTANTS.SELECTORS.CONTAINER)
) {
const modelSelector = headerLeft.querySelector(
CONSTANTS.SELECTORS.MODEL_SELECTOR
);
const container = UI.createDropdownUI();
if (modelSelector) {
headerLeft.insertBefore(container, modelSelector);
} else {
headerLeft.appendChild(container);
}
UI.updateTriggerUI();
console.log("[QwenPersona] UI injected");
PersonaManager.autoSelectPersonaForChat();
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(checkNavbar, 200);
} else {
console.warn(
"[QwenPersona] Failed to find navbar after",
maxAttempts,
"attempts"
);
}
};
checkNavbar();
},
};
// ==================== Model Service ====================
const ModelManager = {
async fetchModels() {
try {
const response = await fetch("/api/models", {
method: "GET",
headers: {
Accept: "application/json",
source: "web",
},
});
if (response.ok) {
const data = await response.json();
State.availableModels = data.data || data || [];
localStorage.setItem(
CONSTANTS.STORAGE.MODELS_CACHE,
JSON.stringify(State.availableModels)
);
console.log(
"[QwenPersona] Models loaded:",
State.availableModels.length
);
}
} catch (e) {
console.error("[QwenPersona] Failed to fetch models:", e);
try {
const cached = localStorage.getItem(CONSTANTS.STORAGE.MODELS_CACHE);
if (cached) State.availableModels = JSON.parse(cached);
} catch (err) {}
}
},
async simulateModelSelection(modelId) {
if (!modelId) return false;
console.log("[QwenPersona] Attempting to select model via UI:", modelId);
const hideStyle = document.createElement("style");
hideStyle.id = "persona-hide-model-selector";
hideStyle.textContent = `
${CONSTANTS.SELECTORS.MODEL_SELECTOR_DROPDOWN},
.ant-dropdown,
.ant-select-dropdown {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
display: none !important;
transition: none !important;
animation: none !important;
}
`;
document.head.appendChild(hideStyle);
try {
const modelTriggerContent = document.querySelector(
CONSTANTS.SELECTORS.MODEL_SELECTOR_CONTENT
);
if (!modelTriggerContent) {
console.warn(
"[QwenPersona] Model selector trigger content not found"
);
return false;
}
const modelTrigger = modelTriggerContent.closest(
CONSTANTS.SELECTORS.ANT_DROPDOWN_TRIGGER
);
if (!modelTrigger) {
console.warn("[QwenPersona] Model selector trigger not found");
return false;
}
modelTrigger.click();
console.log("[QwenPersona] Clicked model selector trigger");
const menuSelector = CONSTANTS.SELECTORS.MODEL_SELECTOR_DROPDOWN;
let menu = await Utils.waitForElement(menuSelector, 2000);
if (!menu) {
console.warn("[QwenPersona] Model selector menu not found");
return false;
}
let modelButton = ModelManager.findModelButton(menu, modelId);
if (!modelButton) {
const expandBtn = menu.querySelector(
CONSTANTS.SELECTORS.MODEL_SELECTOR_VIEW_MORE
);
console.log("[QwenPersona] Expand button:", expandBtn);
if (expandBtn) {
const textEl = expandBtn.querySelector(
CONSTANTS.SELECTORS.MODEL_VIEW_MORE_TEXT
);
const text = textEl ? textEl.textContent : "";
const isExpanded = text.includes("折叠");
console.log("[QwenPersona] Menu expanded state:", isExpanded);
if (!isExpanded) {
console.log("[QwenPersona] Clicking expand button...");
expandBtn.click();
await Utils.sleep(400);
menu = document.querySelector(menuSelector);
if (menu) {
modelButton = ModelManager.findModelButton(menu, modelId);
}
}
} else {
console.log("[QwenPersona] No expand button found in menu");
}
}
if (modelButton) {
const itemContainer = modelButton.closest(
CONSTANTS.SELECTORS.MODEL_SELECTOR_ITEM
);
if (
itemContainer &&
Array.from(itemContainer.classList).some((c) =>
c.includes("index-module__model-selector-item-selected")
)
) {
console.log("[QwenPersona] Model already selected:", modelId);
ModelManager.closeModelSelector();
return true;
}
modelButton.click();
console.log("[QwenPersona] Clicked model button:", modelId);
return true;
} else {
console.warn("[QwenPersona] Model button not found for:", modelId);
ModelManager.closeModelSelector();
return false;
}
} catch (e) {
console.error("[QwenPersona] Error selecting model:", e);
return false;
} finally {
await Utils.sleep(50);
const style = document.getElementById("persona-hide-model-selector");
if (style) style.remove();
}
},
findModelButton(menu, modelId) {
const modelItems = menu.querySelectorAll(
CONSTANTS.SELECTORS.MODEL_SELECTOR_ITEM
);
console.log(
"[QwenPersona] Looking for model:",
modelId,
"in",
modelItems.length,
"items"
);
const normalizedId = modelId.toLowerCase().replace(/[-_]/g, "");
for (const item of modelItems) {
const nameEl = item.querySelector(CONSTANTS.SELECTORS.MODEL_NAME_TEXT);
if (nameEl) {
const modelName = nameEl.textContent.trim();
const normalizedName = modelName.toLowerCase().replace(/[-_]/g, "");
if (normalizedName === normalizedId) {
console.log("[QwenPersona] Found exact match:", modelName);
return item;
}
if (
normalizedId.includes(normalizedName) ||
normalizedName.includes(normalizedId)
) {
console.log("[QwenPersona] Found partial match:", modelName);
return item;
}
}
}
for (const item of modelItems) {
const nameEl = item.querySelector(CONSTANTS.SELECTORS.MODEL_NAME_TEXT);
if (nameEl) {
const modelName = nameEl.textContent.trim();
const mainPart = modelId
.split("-")
.slice(0, 2)
.join("-")
.toLowerCase();
const namePart = modelName
.split("-")
.slice(0, 2)
.join("-")
.toLowerCase();
if (mainPart === namePart) {
console.log("[QwenPersona] Found loose match:", modelName);
return item;
}
}
}
console.log("[QwenPersona] No match found for:", modelId);
return null;
},
closeModelSelector() {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
keyCode: 27,
which: 27,
bubbles: true,
})
);
setTimeout(() => {
const backdrop = document
.querySelector(".selector-modal-list")
?.closest(".fixed");
if (backdrop) {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(event);
}
}, 50);
},
};
// ==================== Feature Service ====================
const FeatureManager = {
findDeepThinkingButton() {
const buttons = document.querySelectorAll(
CONSTANTS.SELECTORS.CHAT_INPUT_FEATURE_BTN
);
for (const btn of buttons) {
const use = btn.querySelector("use");
if (
use &&
use.getAttribute("xlink:href")?.includes("icon-line-deepthink-01")
) {
return btn;
}
if (btn.querySelector(".icon-line-deepthink-01")) {
return btn;
}
}
for (const btn of buttons) {
const textEl = btn.querySelector(
CONSTANTS.SELECTORS.CHAT_INPUT_FEATURE_TEXT
);
if (textEl && textEl.textContent.includes("深度思考")) {
return btn;
}
}
return null;
},
findWebSearchButton() {
const btn = document.querySelector(CONSTANTS.SELECTORS.WEB_SEARCH_BTN);
if (btn) return btn;
const buttons = document.querySelectorAll(
CONSTANTS.SELECTORS.CHAT_INPUT_FEATURE_BTN
);
for (const b of buttons) {
const use = b.querySelector("use");
if (
use &&
use.getAttribute("xlink:href")?.includes("icon-line-globe-01")
) {
return b;
}
if (b.querySelector(".icon-line-globe-01")) {
return b;
}
}
for (const b of buttons) {
const textEl = b.querySelector(
CONSTANTS.SELECTORS.CHAT_INPUT_FEATURE_TEXT
);
if (textEl && textEl.textContent.includes("搜索")) {
return b;
}
}
return null;
},
isFeatureButtonActive(button) {
if (!button) return false;
if (button.classList.contains("active")) return true;
if (button.getAttribute("aria-pressed") === "true") return true;
if (button.dataset.state === "active" || button.dataset.state === "on")
return true;
const icon = button.querySelector('i[class*="icon-"]');
if (icon && icon.classList.contains("icon-fill-deepthink-01"))
return true;
if (icon && icon.classList.contains("icon-fill-globe-01")) return true;
const style = window.getComputedStyle(button);
const bgColor = style.backgroundColor;
if (
bgColor &&
bgColor !== "rgba(0, 0, 0, 0)" &&
bgColor !== "transparent"
) {
// Active
}
return false;
},
async simulateDeepThinking(enabled) {
console.log("[QwenPersona] Setting deep thinking to:", enabled);
const button = FeatureManager.findDeepThinkingButton();
if (!button) {
console.warn("[QwenPersona] Deep thinking button not found");
return false;
}
const currentlyActive = FeatureManager.isFeatureButtonActive(button);
console.log(
"[QwenPersona] Deep thinking currently active:",
currentlyActive
);
if (currentlyActive !== enabled) {
button.click();
console.log("[QwenPersona] Clicked deep thinking button");
await Utils.sleep(100);
return true;
} else {
console.log("[QwenPersona] Deep thinking already in desired state");
return true;
}
},
async simulateWebSearch(enabled) {
console.log("[QwenPersona] Setting web search to:", enabled);
const button = FeatureManager.findWebSearchButton();
if (!button) {
console.warn("[QwenPersona] Web search button not found");
return false;
}
const currentlyActive = FeatureManager.isFeatureButtonActive(button);
console.log(
"[QwenPersona] Web search currently active:",
currentlyActive
);
if (currentlyActive !== enabled) {
button.click();
console.log("[QwenPersona] Clicked web search button");
await Utils.sleep(100);
return true;
} else {
console.log("[QwenPersona] Web search already in desired state");
return true;
}
},
async applyFeatureSettings(persona) {
if (!persona) return;
await Utils.sleep(200);
const deepThinkingEnabled = persona.deepThinking === true;
await FeatureManager.simulateDeepThinking(deepThinkingEnabled);
const webSearchEnabled = persona.webSearch === true;
await FeatureManager.simulateWebSearch(webSearchEnabled);
},
};
// ==================== Chat Service ====================
const ChatManager = {
getCurrentChatId(url = location.pathname) {
const match = url.match(/\/(?:c|chat)\/([a-zA-Z0-9-]+)/);
return match ? match[1] : null;
},
setPersonaForCurrentChat(personaId) {
const chatId = ChatManager.getCurrentChatId();
if (chatId) {
if (personaId) {
State.chatPersonaMap[chatId] = personaId;
} else {
delete State.chatPersonaMap[chatId];
}
Storage.saveChatPersonaMap();
console.log(
"[QwenPersona] Mapped chat",
chatId,
"to persona",
personaId
);
}
},
getPersonaForCurrentChat() {
const chatId = ChatManager.getCurrentChatId();
if (chatId && State.chatPersonaMap[chatId]) {
return State.chatPersonaMap[chatId];
}
return null;
},
startUrlMonitor() {
State.lastUrl = location.href;
window.addEventListener("popstate", ChatManager.handleUrlChange);
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args) {
originalPushState.apply(this, args);
ChatManager.handleUrlChange();
};
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
ChatManager.handleUrlChange();
};
const navbarObserver = new MutationObserver(() => {
if (!document.getElementById(CONSTANTS.SELECTORS.CONTAINER)) {
console.log("[QwenPersona] Dropdown removed, re-injecting...");
UI.waitForNavbar();
}
});
const startNavbarObserver = () => {
const navbar = document.querySelector(
CONSTANTS.SELECTORS.HEADER_DESKTOP
);
if (navbar) {
navbarObserver.observe(navbar, { childList: true, subtree: true });
console.log("[QwenPersona] Navbar observer started");
} else {
setTimeout(startNavbarObserver, 500);
}
};
startNavbarObserver();
console.log("[QwenPersona] URL monitor started");
},
handleUrlChange() {
const currentUrl = location.href;
if (currentUrl === State.lastUrl) return;
console.log(
"[QwenPersona] URL changed:",
State.lastUrl,
"->",
currentUrl
);
const prevChatId = ChatManager.getCurrentChatId(State.lastUrl);
const currChatId = ChatManager.getCurrentChatId(currentUrl);
if (
!prevChatId &&
currChatId &&
State.selectedPersonaId &&
!State.chatPersonaMap[currChatId]
) {
console.log(
"[QwenPersona] New chat detected, mapping current persona:",
State.selectedPersonaId
);
State.chatPersonaMap[currChatId] = State.selectedPersonaId;
Storage.saveChatPersonaMap();
}
State.lastUrl = currentUrl;
setTimeout(() => {
if (!document.getElementById(CONSTANTS.SELECTORS.CONTAINER)) {
console.log("[QwenPersona] Re-injecting UI after navigation");
UI.waitForNavbar();
}
PersonaManager.autoSelectPersonaForChat();
}, 300);
},
};
// ==================== Persona Manager ====================
const PersonaManager = {
selectPersona(id) {
State.selectedPersonaId = id || null;
Storage.saveSelectedPersona();
UI.updateTriggerUI();
UI.closeDropdown();
console.log("[QwenPersona] Selected:", id || "None");
ChatManager.setPersonaForCurrentChat(id);
setTimeout(async () => {
UI.setInteractionState(true);
try {
if (id) {
const persona = State.personas.find((p) => p.id === id);
if (persona) {
if (persona.model || persona.modelName) {
const modelToSelect = persona.modelName || persona.model;
await ModelManager.simulateModelSelection(modelToSelect);
}
await FeatureManager.applyFeatureSettings(persona);
}
} else {
await FeatureManager.applyFeatureSettings({
deepThinking: false,
webSearch: false,
});
}
} finally {
UI.setInteractionState(false);
}
}, 100);
},
editPersona(id) {
const persona = State.personas.find((p) => p.id === id);
if (persona) {
UI.openModal(persona);
}
},
deletePersona(id) {
if (!confirm(I18n.t("deleteConfirm"))) return;
State.personas = State.personas.filter((p) => p.id !== id);
Storage.savePersonas();
if (State.selectedPersonaId === id) {
State.selectedPersonaId = null;
Storage.saveSelectedPersona();
UI.updateTriggerUI();
}
UI.renderDropdownMenu();
console.log("[QwenPersona] Deleted:", id);
},
savePersonaFromModal(existingId = null) {
const name = document
.getElementById(CONSTANTS.SELECTORS.INPUT_NAME)
.value.trim();
const emoji = document.getElementById(
CONSTANTS.SELECTORS.INPUT_EMOJI
).value;
const model = document.getElementById(
CONSTANTS.SELECTORS.INPUT_MODEL
).value;
const prompt = document
.getElementById(CONSTANTS.SELECTORS.INPUT_PROMPT)
.value.trim();
const deepThinking = document.getElementById(
CONSTANTS.SELECTORS.INPUT_DEEP_THINKING
).checked;
const webSearch = document.getElementById(
CONSTANTS.SELECTORS.INPUT_WEB_SEARCH
).checked;
if (!name) {
alert("请输入 Persona 名称");
return;
}
const modelObj = State.availableModels.find((m) => m.id === model);
const modelName = modelObj ? modelObj.name : "";
if (existingId) {
const idx = State.personas.findIndex((p) => p.id === existingId);
if (idx !== -1) {
State.personas[idx] = {
...State.personas[idx],
name,
emoji,
model,
modelName,
prompt,
deepThinking,
webSearch,
};
}
} else {
const newPersona = {
id: "persona_" + Date.now(),
name,
emoji,
model,
modelName,
prompt,
deepThinking,
webSearch,
createdAt: Date.now(),
};
State.personas.push(newPersona);
}
Storage.savePersonas();
UI.updateTriggerUI();
UI.closeModal();
console.log("[QwenPersona] Saved persona:", name);
},
async importFromUrl(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Network response was not ok");
const data = await response.json();
if (!Array.isArray(data))
throw new Error("Invalid JSON format: expected an array");
// Basic validation
const isValid = data.every((p) => p.id && p.name);
if (!isValid) throw new Error("Invalid persona data");
State.personas = data;
Storage.savePersonas();
// Reset selection if current selection is not in new list
if (
State.selectedPersonaId &&
!State.personas.find((p) => p.id === State.selectedPersonaId)
) {
State.selectedPersonaId = null;
Storage.saveSelectedPersona();
UI.updateTriggerUI();
}
UI.renderDropdownMenu();
alert(I18n.t("importSuccess"));
} catch (e) {
console.error("[QwenPersona] Import failed:", e);
alert(I18n.t("importError") + "\n" + e.message);
}
},
autoSelectPersonaForChat() {
const chatId = ChatManager.getCurrentChatId();
if (!chatId) {
if (State.selectedPersonaId) {
console.log("[QwenPersona] No chat ID (Home/New), resetting to None");
PersonaManager.selectPersona(null);
}
return;
}
const recordedPersonaId = State.chatPersonaMap[chatId];
if (recordedPersonaId) {
const personaExists = State.personas.find(
(p) => p.id === recordedPersonaId
);
if (personaExists) {
if (recordedPersonaId !== State.selectedPersonaId) {
console.log(
"[QwenPersona] Auto-selecting persona for chat:",
chatId,
"->",
recordedPersonaId
);
PersonaManager.selectPersona(recordedPersonaId);
}
} else {
if (State.selectedPersonaId) PersonaManager.selectPersona(null);
}
} else {
if (State.selectedPersonaId) {
console.log(
"[QwenPersona] No mapping for this chat, resetting to None"
);
PersonaManager.selectPersona(null);
}
}
},
};
// ==================== Network Service ====================
const NetworkManager = {
interceptFetch() {
const originalFetch = window.fetch;
window.fetch = async function (url, options = {}) {
if (
typeof url === "string" &&
url.includes("/api/v2/chat/completions")
) {
const persona = State.personas.find(
(p) => p.id === State.selectedPersonaId
);
if (persona && options.body) {
try {
let body = JSON.parse(options.body);
let modified = false;
// Detect if this is an edit action (user editing a previous message)
const isEditAction =
Array.isArray(body.messages) &&
body.messages.some((m) => m.user_action === "edit");
// Detect if this is truly a new chat (no existing conversation context)
const isNewChat =
(!body.conversation_id &&
!body.conversationId &&
!body.chat_id) ||
(!body.parent_id &&
!body.parentId &&
body.messages &&
body.messages.length === 1 &&
!isEditAction);
const hasSystemMessage =
Array.isArray(body.messages) &&
body.messages.some((m) => m.role === "system");
// Check if chat_id exists in URL (indicates existing conversation)
const urlChatIdMatch = url.match(/chat_id=([a-zA-Z0-9-]+)/);
const urlHasChatId = !!urlChatIdMatch;
console.log("[QwenPersona] Debug - Request Check:", {
url,
isNewChat,
isEditAction,
hasSystemMessage,
urlHasChatId,
chat_id: body.chat_id,
parent_id: body.parent_id || body.parentId,
messageCount: body.messages ? body.messages.length : 0,
personaPrompt: !!persona.prompt,
});
if (persona.prompt && Array.isArray(body.messages)) {
const systemMsgIndex = body.messages.findIndex(
(m) => m.role === "system"
);
// For edit actions: the server does NOT retain the system message
// We need to inject the system prompt into the user message instead
// because the API only allows one system message at the conversation root
if (isEditAction && urlHasChatId) {
// Remove any existing system message first (to avoid duplicates)
if (systemMsgIndex !== -1) {
console.log(
"[QwenPersona] Debug - Removing existing System Message for edit action"
);
body.messages.splice(systemMsgIndex, 1);
}
// Prepend system prompt to user message content
const userMsgIndex = body.messages.findIndex(
(m) => m.role === "user"
);
if (userMsgIndex !== -1) {
const userMsg = body.messages[userMsgIndex];
if (!userMsg.content.startsWith(persona.prompt)) {
console.log(
"[QwenPersona] Debug - Prepending System Prompt to User Message (Edit Action)"
);
userMsg.content = `[System Instruction]\n${persona.prompt}\n\n[User Message]\n${userMsg.content}`;
modified = true;
}
}
} else if (systemMsgIndex !== -1) {
console.log(
"[QwenPersona] Debug - Updating existing System Prompt"
);
body.messages[systemMsgIndex].content = persona.prompt;
// Ensure it is the first message
if (systemMsgIndex !== 0) {
const [msg] = body.messages.splice(systemMsgIndex, 1);
body.messages.unshift(msg);
}
modified = true;
} else {
// No system message found
// Check if this is the start of a conversation (no parent_id)
// If parent_id exists, it's a continuation, and we CANNOT inject a system message (server restriction)
const isStartOfConversation =
!body.parent_id && !body.parentId && !isEditAction;
if (isStartOfConversation && !urlHasChatId) {
console.log(
"[QwenPersona] Debug - Injecting System Prompt (New Chat/Root)"
);
body.messages.unshift({
role: "system",
content: persona.prompt,
});
modified = true;
} else {
console.log(
"[QwenPersona] Debug - Prepending System Prompt to User Message (Continuation)"
);
const lastMsg = body.messages[body.messages.length - 1];
if (
lastMsg &&
lastMsg.role === "user" &&
!lastMsg.content.startsWith(persona.prompt) &&
!lastMsg.content.startsWith("[System Instruction]")
) {
lastMsg.content = `[System Instruction]\n${persona.prompt}\n\n[User Message]\n${lastMsg.content}`;
modified = true;
}
}
}
}
if (persona.model) {
body.model = persona.model;
modified = true;
}
if (modified) {
options.body = JSON.stringify(body);
console.log(
"[QwenPersona] Request modified with persona:",
persona.name
);
}
} catch (e) {
console.error("[QwenPersona] Failed to modify request:", e);
}
}
}
return originalFetch.call(this, url, options);
};
console.log("[QwenPersona] Fetch intercepted");
},
};
// ==================== Initialization ====================
function init() {
console.log("[QwenPersona] Initializing...");
I18n.init();
Storage.loadPersonas();
Storage.loadSelectedPersona();
Storage.loadChatPersonaMap();
ModelManager.fetchModels();
UI.injectStyles();
UI.createModalUI();
// Load emoji-picker-element
const emojiScript = document.createElement("script");
emojiScript.type = "module";
emojiScript.src =
"https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js";
document.head.appendChild(emojiScript);
NetworkManager.interceptFetch();
UI.waitForNavbar();
ChatManager.startUrlMonitor();
window.personaManager = {
closeModal: UI.closeModal,
openModal: UI.openModal,
refresh: () => {
Storage.loadPersonas();
Storage.loadSelectedPersona();
UI.updateTriggerUI();
},
};
document.addEventListener("click", (e) => {
const container = document.getElementById(CONSTANTS.SELECTORS.CONTAINER);
const menu = document.getElementById(CONSTANTS.SELECTORS.MENU);
const isClickInsideContainer = container && container.contains(e.target);
const isClickInsideMenu = menu && menu.contains(e.target);
if (!isClickInsideContainer && !isClickInsideMenu) {
UI.closeDropdown();
}
// Close emoji picker when clicking outside
const emojiPickerContainer = document.getElementById(
"persona-emoji-picker"
);
const emojiBtn = document.getElementById("persona-emoji-trigger-btn");
if (emojiPickerContainer && emojiBtn) {
if (
!emojiPickerContainer.contains(e.target) &&
!emojiBtn.contains(e.target)
) {
emojiPickerContainer.classList.remove("visible");
}
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();