System Prompt Editor for Qwen Chat

Adds the ability to modify system prompts in the Qwen Chat interface to customize AI behavior.

// ==UserScript==
// @name           System Prompt Editor for Qwen Chat
// @name:ru        Редактор системного промпта для Qwen Chat
// @namespace      https://chat.qwen.ai/
// @version        2025-04-12
// @description    Adds the ability to modify system prompts in the Qwen Chat interface to customize AI behavior.
// @description:ru Добавляет возможность изменения системных промптов в интерфейсе Qwen Chat для настройки поведения ИИ.
// @author         Mikhail Zuenko
// @match          https://chat.qwen.ai/*
// @icon           https://www.google.com/s2/favicons?sz=64&domain=qwen.ai
// @grant          unsafeWindow
// ==/UserScript==

function generateUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        const r = Math.random() * 16 | 0;
        const v = c === 'x' ? r : (r & 0x3) | 0x8;
        return v.toString(16);
    });
}

function getSystemPromptMessage(data) {
    const rootMessages = Object.values(data.chat.history.messages).filter(msg => msg.parentId === null);
    const promptMessage = rootMessages.find(msg => msg.role === 'system');
    return promptMessage || null;
}

function setSystemPrompt(data, systemPrompt) {
    const promptMessage = getSystemPromptMessage(data);
    if (promptMessage) {
        promptMessage.content = systemPrompt;
        data.chat.messages.find(msg => msg.id === promptMessage.id).content = systemPrompt;
    }
    else {
        const rootMessages = Object.values(data.chat.history.messages).filter(msg => msg.parentId === null);

        const promptMessage = {
            id: generateUUID(),
            parentId: null,
            childrenIds: rootMessages.map(msg => msg.id),
            role: 'system',
            content: systemPrompt
        };
        for (const message of rootMessages) {
            message.parentId = promptMessage.id;
        }
        data.chat.history.messages[promptMessage.id] = promptMessage;

        let firstIndex = null;
        for (let msg = 0; msg < data.chat.messages.length; ++msg) {
            if (data.chat.messages[msg].parentId === null) {
                data.chat.messages[msg].parentId = promptMessage.id;
                if (firstIndex === null) firstIndex = msg;
            }
        }
        data.chat.messages.splice(firstIndex, 0, promptMessage);
    }
}

function deleteSystemPrompt(data) {
    const promptMessage = getSystemPromptMessage(data);
    if (!promptMessage) return;

    const children = promptMessage.childrenIds;
    for (const childId of children) {
        data.chat.history.messages[childId].parentId = null;
    }
    for (const message of data.chat.messages) {
        if (children.includes(message.id)) message.parentId = null;
    }
    data.chat.messages.splice(data.chat.messages.findIndex(msg => msg.id === promptMessage.id), 1);
    delete data.chat.history.messages[promptMessage.id];
}

let origFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async (input, init) => {
    if (init && init._noChange) return origFetch(input, init);
    if (init && input === '/api/chat/completions') {
        const body = JSON.parse(init.body);
        const promptMessage = getSystemPromptMessage(await request('/api/v1/chats/' + body.chat_id));
        if (promptMessage) {
            body.messages.unshift({ role: 'system', content: promptMessage.content });
            init.body = JSON.stringify(body);
        }
    }
    else if (typeof input === 'string') {
        if (/^\/api\/v1\/chats\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/?$/.test(input)) {
            if (init && init.method === 'POST') {
                const promptMessage = getSystemPromptMessage(await request(input));
                if (promptMessage) {
                    const body = JSON.parse(init.body);
                    setSystemPrompt(body, promptMessage.content);
                    init.body = JSON.stringify(body);
                }
            }
            const res = await origFetch(input, init);
            const data = await res.json();
            deleteSystemPrompt(data);
            return new Response(JSON.stringify(data), {
                status: res.status,
                statusText: res.statusText,
                headers: res.headers
            });
        }
    }
    return origFetch(input, init);
};

function getIdFromUrl() {
    const path = location.pathname.match(/^\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/);
    return path ? path[1] : null;
}
function request(input, init) {
    return unsafeWindow.fetch(input, { _noChange: true, ...init }).then(res => res.json());
}

function $E(tag, props, children) {
    const elem = document.createElement(tag);
    for (const prop in props) {
        if (prop.startsWith('on')) elem.addEventListener(prop.slice(2).toLowerCase(), props[prop]);
        else if (prop === 'classes') elem.classList.add(props[prop]);
        else {
            const snakeProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
            if (props[prop] === true) elem.setAttribute(snakeProp, '');
            else elem.setAttribute(snakeProp, props[prop]);
        }
    }
    elem.append(...children);
    return elem;
}
function $T(text) {
    return document.createTextNode(text || '');
}

const textarea = $E('textarea', {
    class: 'block w-full h-[200px] p-2 bg-white dark:bg-[#2A2A2A] text-[#2C2C36] dark:text-[#FAFAFC] rounded-lg resize-none',
    placeholder: 'How should I answer you?'
}, [])

const button = $E(
    'button',
    {
        class: 'flex-none size-9 cursor-pointer rounded-xl transition hover:bg-gray-50 dark:hover:bg-gray-850',
        async onClick() {
            const id = getIdFromUrl();
            if (id) {
                const data = await request('/api/v1/chats/' + id);
                const promptMessage = getSystemPromptMessage(data);
                textarea.value = promptMessage ? promptMessage.content : '';

                document.body.append(editor);
                document.addEventListener('keydown', escCloseEditor);
            }
        }
    },
    [$E('i', { class: 'iconfont leading-none icon-line-message-circle-02' }, [])]
);

const editor = $E('div', {
    class: 'modal fixed inset-0 z-[9999] flex h-full w-full items-center justify-center overflow-hidden bg-black/60',
    onMousedown: closeEditor
}, [
    $E('div', {
        class: 'm-auto max-w-full w-[480px] mx-2 shadow-3xl scrollbar-hidden max-h-[90vh] overflow-y-auto bg-gray-50 dark:bg-gray-900 rounded-2xl',
        onMousedown: event => event.stopPropagation()
    }, [
        $E('div', { class: 'flex justify-between px-5 pb-1 pt-4 dark:text-gray-300' }, [
            $E('div', { class: 'self-center text-lg font-medium' }, [$T('System Prompt')]),
            $E('button', {
                class: 'self-center',
                onClick: closeEditor
            }, [
                $E('i', { class: 'iconfont leading-none icon-line-x-02 font-bold' }, [])
            ])
        ]),
        $E('div', { class: 'px-4 pt-1' }, [textarea]),
        $E('div', { class: 'flex justify-end p-4 pt-3 text-sm font-medium' }, [
            $E('button', {
                class: 'dark:purple-500 dark:hover:purple-400 rounded-full bg-purple-500 px-3.5 py-1.5 text-sm font-medium text-white transition hover:bg-purple-400',
                async onClick() {
                    closeEditor();

                    const id = getIdFromUrl();
                    if (id) {
                        const data = await request('/api/v1/chats/' + id);

                        if (textarea.value === '') deleteSystemPrompt(data);
                        else setSystemPrompt(data, textarea.value);

                        request('/api/v1/chats/' + id, {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify(data)
                        });
                    }
                }
            }, [$T('Save')])
        ])
    ])
]);

function closeEditor() {
    editor.remove();
    document.removeEventListener('keydown', escCloseEditor);
}
function escCloseEditor(event) {
    if (event.code === 'Escape') closeEditor();
}

let lastId = getIdFromUrl();
addEventListener('popstate', () => {
    const newId = getIdFromUrl();
    if (lastId === newId) return;
    closeEditor();
    lastId = newId;
});

new MutationObserver(() => {
    const elem = document.querySelector('#chat-container :has(>[aria-label])>div:not(:has(>button)):not(:empty)');
    if (elem && button.previousElementSibling !== elem) {
        elem.after(button);
    }
}).observe(document.body, { childList: true, subtree: true });