Files
risuai/src/ts/characters.ts
2024-09-18 23:25:02 +09:00

791 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { get, writable } from "svelte/store";
import { DataBase, saveImage, setDatabase, type character, type Chat, defaultSdDataFunc, type loreBook } from "./storage/database";
import { alertAddCharacter, alertConfirm, alertError, alertNormal, alertSelect, alertStore, alertWait } from "./alert";
import { language } from "../lang";
import { decode as decodeMsgpack } from "msgpackr";
import { checkNullish, findCharacterbyId, getUserName, selectMultipleFile, selectSingleFile, sleep } from "./util";
import { v4 as uuidv4 } from 'uuid';
import { MobileGUIStack, OpenRealmStore, selectedCharID } from "./stores";
import { checkCharOrder, downloadFile, getFileSrc } from "./storage/globalApi";
import { reencodeImage } from "./process/files/image";
import { updateInlayScreen } from "./process/inlayScreen";
import { PngChunk } from "./pngChunk";
import { parseMarkdownSafe } from "./parser";
import { translateHTML } from "./translator/translator";
import { doingChat } from "./process";
import { importCharacter } from "./characterCards";
export function createNewCharacter() {
let db = get(DataBase)
db.characters.push(createBlankChar())
setDatabase(db)
checkCharOrder()
return db.characters.length - 1
}
export function createNewGroup(){
let db = get(DataBase)
db.characters.push({
type: 'group',
name: "",
firstMessage: "",
chats: [{
message: [],
note: '',
name: 'Chat 1',
localLore: []
}], chatPage: 0,
viewScreen: 'none',
globalLore: [],
characters: [],
autoMode: false,
useCharacterLore: true,
emotionImages: [],
customscript: [],
chaId: uuidv4(),
firstMsgIndex: -1,
characterTalks: [],
characterActive: []
})
setDatabase(db)
checkCharOrder()
return db.characters.length - 1
}
export async function getCharImage(loc:string, type:'plain'|'css'|'contain'|'lgcss') {
if(!loc || loc === ''){
if(type ==='css'){
return ''
}
return null
}
const filesrc = await getFileSrc(loc)
if(type === 'plain'){
return filesrc
}
else if(type ==='css'){
return `background: url("${filesrc}");background-size: cover;`
}
else if(type ='lgcss'){
return `background: url("${filesrc}");background-size: cover;height: 10.66rem;`
}
else{
return `background: url("${filesrc}");background-size: contain;background-repeat: no-repeat;background-position: center;`
}
}
export async function selectCharImg(charIndex:number) {
const selected = await selectSingleFile(['png', 'webp', 'gif', 'jpg', 'jpeg'])
if(!selected){
return
}
const img = selected.data
let db = get(DataBase)
const imgp = await saveImage(await reencodeImage(img))
dumpCharImage(charIndex)
db.characters[charIndex].image = imgp
setDatabase(db)
}
export function dumpCharImage(charIndex:number) {
let db = get(DataBase)
const char = db.characters[charIndex] as character
if(!char.image || char.image === ''){
return
}
char.ccAssets ??= []
char.ccAssets.push({
type: 'icon',
name: 'iconx',
uri: char.image,
ext: 'png'
})
char.image = ''
db.characters[charIndex] = char
setDatabase(db)
}
export function changeCharImage(charIndex:number,changeIndex:number) {
let db = get(DataBase)
const char = db.characters[charIndex] as character
const image = char.ccAssets[changeIndex].uri
char.ccAssets.splice(changeIndex, 1)
dumpCharImage(charIndex)
char.image = image
db.characters[charIndex] = char
setDatabase(db)
}
export const addingEmotion = writable(false)
export async function addCharEmotion(charId:number) {
addingEmotion.set(true)
const selected = await selectMultipleFile(['png', 'webp', 'gif'])
if(!selected){
addingEmotion.set(false)
return
}
let db = get(DataBase)
for(const f of selected){
const img = f.data
const imgp = await saveImage(img)
const name = f.name.replace('.png','').replace('.webp','')
let dbChar = db.characters[charId]
if(dbChar.type !== 'group'){
dbChar.emotionImages.push([name,imgp])
db.characters[charId] = dbChar
}
setDatabase(db)
}
addingEmotion.set(false)
}
export async function rmCharEmotion(charId:number, emotionId:number) {
let db = get(DataBase)
let dbChar = db.characters[charId]
if(dbChar.type !== 'group'){
dbChar.emotionImages.splice(emotionId, 1)
db.characters[charId] = dbChar
}
setDatabase(db)
}
export async function exportChat(page:number){
try {
const mode = await alertSelect(['Export as JSON', "Export as TXT", "Export as HTML File", "Export as HTML Embed"])
const doTranslate = (mode === '2' || mode === '3') ? (await alertSelect([language.translateContent, language.doNotTranslate])) === '0' : false
const anonymous = (mode === '2' || mode === '3') ? ((await alertSelect([language.includePersonaName, language.hidePersonaName])) === '1') : false
const selectedID = get(selectedCharID)
const db = get(DataBase)
const chat = db.characters[selectedID].chats[page]
const char = db.characters[selectedID]
const date = new Date().toJSON();
const htmlChatParse = async (v:string) => {
v = parseMarkdownSafe(v)
if(doTranslate){
v = await translateHTML(v, false, '', -1)
}
if(anonymous){
//case insensitive match, replace all
const excapedName = char.name.replace(/[-\/\\^$*+\?\.()|[\]{}]/g, '\\$&')
v = v.replace(new RegExp(`${excapedName}`, 'gi'), '×××')
}
return v
}
if(mode === '0'){
const stringl = Buffer.from(JSON.stringify({
type: 'risuChat',
ver: 1,
data: chat
}), 'utf-8')
await downloadFile(`${char.name}_${date}_chat`.replace(/[<>:"/\\|?*\.\,]/g, "") + '.json', stringl)
}
else if(mode === '2'){
let chatContentHTML = ''
let i = 0
for(const v of chat.message){
alertWait(`Translating... ${i++}/${chat.message.length}`)
const name = v.saying ? findCharacterbyId(v.saying).name : v.role === 'char' ? char.name : anonymous ? '×××' : getUserName()
chatContentHTML += `<div class="chat">
<h2>${name}</h2>
<div>${await htmlChatParse(v.data)}</div>
</div>`
}
const doc = `
<!DOCTYPE html>
<html>
<head>
<title>${char.name} Chat</title>
<style>
body{
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
}
.container{
max-width: 800px;
padding: 1rem;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat{
background: #f0f0f0;
padding: 1rem;
border-radius: 10px;
display: flex;
flex-direction: column;
}
.idat{
display: none;
}
h2{
margin: 0;
}
.chat div{
margin-top: 0.5rem;
break-word: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="chat">
<h2>${char.name}</h2>
<div>${await htmlChatParse(
chat.fmIndex === -1 ? char.firstMessage : char.alternateGreetings?.[chat.fmIndex ?? 0]
)}</div>
</div>
${chatContentHTML}
</div>
<div class="idat">${
JSON.stringify(chat).replace(/</g, '&lt;').replace(/>/g, '&gt;')
}</div>
</body>
`
await downloadFile(`${char.name}_${date}_chat`.replace(/[<>:"/\\|?*\.\,]/g, "") + '.html', Buffer.from(doc, 'utf-8'))
}
else if(mode === '3'){
//create a html table
let chatContentHTML = ''
let i = 0
for(const v of chat.message){
alertWait(`Translating... ${i++}/${chat.message.length}`)
const name = v.saying ? findCharacterbyId(v.saying).name : v.role === 'char' ? char.name : anonymous ? '×××' : getUserName()
chatContentHTML += `<tr>
<td>${name}</td>
<td>${await htmlChatParse(v.data)}</td>
</tr>`
}
const template = `
<table>
<tr>
<th>Character</th>
<th>Message</th>
</tr>
<tr>
<td>${char.name}</td>
<td>${await htmlChatParse(char.firstMessage)}</td>
</tr>
${chatContentHTML}
</table>
<p>Chat from RisuAI</p>
`
//copy to clipboard
const item = new ClipboardItem({
'text/html': new Blob([template], { type: 'text/html' }),
'text/plain': new Blob([template], { type: 'text/plain' })
})
await navigator.clipboard.write([item])
alertNormal(language.clipboardSuccess)
return
}
else{
let stringl = chat.message.map((v) => {
if(v.saying){
return `--${findCharacterbyId(v.saying).name}\n${v.data}`
}
else{
return `--${v.role === 'char' ? char.name : getUserName()}\n${v.data}`
}
}).join('\n\n')
if(char.type !== 'group'){
stringl = `--${char.name}\n${char.firstMessage}\n\n` + stringl
}
await downloadFile(`${char.name}_${date}_chat`.replace(/[<>:"/\\|?*\.\,]/g, "") + '.txt', Buffer.from(stringl, 'utf-8'))
}
alertNormal(language.successExport)
} catch (error) {
alertError(`${error}`)
}
}
export async function importChat(){
const dat =await selectSingleFile(['json','jsonl','txt','html'])
if(!dat){
return
}
try {
const selectedID = get(selectedCharID)
let db = get(DataBase)
if(dat.name.endsWith('jsonl')){
const lines = Buffer.from(dat.data).toString('utf-8').split('\n')
let newChat:Chat = {
message: [],
note: "",
name: "Imported Chat",
localLore: []
}
let isFirst = true
for(const line of lines){
const presedLine = JSON.parse(line)
if(presedLine.name && presedLine.is_user, presedLine.mes){
if(!isFirst){
newChat.message.push({
role: presedLine.is_user ? "user" : 'char',
data: formatTavernChat(presedLine.mes, db.characters[selectedID].name)
})
}
}
isFirst = false
}
if(newChat.message.length === 0){
alertError(language.errors.noData)
return
}
db.characters[selectedID].chats.unshift(newChat)
setDatabase(db)
alertNormal(language.successImport)
}
else if(dat.name.endsWith('json')){
const json = JSON.parse(Buffer.from(dat.data).toString('utf-8'))
if(json.type === 'risuChat' && json.ver === 1){
const das:Chat = json.data
if(!(checkNullish(das.message) || checkNullish(das.note) || checkNullish(das.name) || checkNullish(das.localLore))){
db.characters[selectedID].chats.unshift(das)
setDatabase(db)
alertNormal(language.successImport)
return
}
else{
alertError(language.errors.noData)
return
}
}
else{
alertError(language.errors.noData)
return
}
}
else if(dat.name.endsWith('html')){
const doc = new DOMParser().parseFromString(Buffer.from(dat.data).toString('utf-8'), 'text/html')
const chat = doc.querySelector('.idat').textContent
const json = JSON.parse(chat)
if(json.message && json.note && json.name && json.localLore){
db.characters[selectedID].chats.unshift(json)
setDatabase(db)
alertNormal(language.successImport)
}
else{
alertError(language.errors.noData)
}
}
} catch (error) {
alertError(`${error}`)
}
}
function formatTavernChat(chat:string, charName:string){
const db = get(DataBase)
return chat.replace(/<([Uu]ser)>|\{\{([Uu]ser)\}\}/g, getUserName()).replace(/((\{\{)|<)([Cc]har)(=.+)?((\}\})|>)/g, charName)
}
export function characterFormatUpdate(index:number|character, arg:{
updateInteraction?:boolean,
} = {}){
let db = get(DataBase)
let cha = typeof(index) === 'number' ? db.characters[index] : index
if(cha.chats.length === 0){
cha.chats = [{
message: [],
note: '',
name: 'Chat 1',
localLore: []
}]
}
if(!cha.chats[cha.chatPage]){
cha.chatPage = 0
}
if(!cha.chats[cha.chatPage].message){
cha.chats[cha.chatPage].message = []
}
if(!cha.type){
cha.type = 'character'
}
if(!cha.chaId){
cha.chaId = uuidv4()
}
if(cha.type !== 'group'){
if(checkNullish(cha.sdData)){
cha.sdData = defaultSdDataFunc()
}
if(checkNullish(cha.utilityBot)){
cha.utilityBot = false
}
cha.triggerscript = cha.triggerscript ?? []
cha.alternateGreetings = cha.alternateGreetings ?? []
cha.exampleMessage = cha.exampleMessage ?? ''
cha.creatorNotes = cha.creatorNotes ?? ''
cha.systemPrompt = cha.systemPrompt ?? ''
cha.tags = cha.tags ?? []
cha.creator = cha.creator ?? ''
cha.characterVersion = cha.characterVersion ?? ''
cha.personality = cha.personality ?? ''
cha.scenario = cha.scenario ?? ''
cha.firstMsgIndex = cha.firstMsgIndex ?? -1
cha.additionalData = cha.additionalData ?? {
tag: [],
creator: '',
character_version: ''
}
cha.voicevoxConfig = cha.voicevoxConfig ?? {
SPEED_SCALE: 1,
PITCH_SCALE: 0,
INTONATION_SCALE: 1,
VOLUME_SCALE: 1
}
if(cha.postHistoryInstructions){
cha.chats[cha.chatPage].note += "\n" + cha.postHistoryInstructions
cha.chats[cha.chatPage].note = cha.chats[cha.chatPage].note.trim()
cha.postHistoryInstructions = null
}
cha.additionalText ??= ''
cha.depth_prompt ??= {
depth: 0,
prompt: ''
}
cha.hfTTS ??= {
model: '',
language: 'en'
}
cha.backgroundHTML ??= ''
cha.backgroundCSS ??= ''
cha.creation_date ??= Date.now()
cha.globalLore = updateLorebooks(cha.globalLore)
if(!cha.newGenData){
cha = updateInlayScreen(cha)
}
cha.ttsMode ||= 'none'
}
else{
if((!cha.characterTalks) || cha.characterTalks.length !== cha.characters.length){
cha.characterTalks = []
for(let i=0;i<cha.characters.length;i++){
cha.characterTalks.push(1 / 6 * 4)
}
}
if((!cha.characterActive) || cha.characterActive.length !== cha.characters.length){
cha.characterActive = []
for(let i=0;i<cha.characters.length;i++){
cha.characterActive.push(true)
}
}
}
if(checkNullish(cha.customscript)){
cha.customscript = []
}
cha.lastInteraction = Date.now()
if(typeof(index) === 'number'){
db.characters[index] = cha
setDatabase(db)
}
cha.chats = cha.chats.map((v) => {
v.fmIndex ??= cha.firstMsgIndex ?? -1
return v
})
return cha
}
export function updateLorebooks(book:loreBook[]){
return book.map((v) => {
v.bookVersion ??= 1
if(v.bookVersion >= 2){
return v
}
if(v.activationPercent){
const perc = v.activationPercent
v.activationPercent = null
v.content = `@@probability ${perc}\n${v.content}`
}
v.content = v.content.replace(/@@@?end/g, '@@depth 0').replace(/\<(char|bot)\>/g, '{{char}}').replace(/\<(user)\>/g, '{{user}}')
v.bookVersion = 2
return v
})
}
export function createBlankChar():character{
return {
name: '',
firstMessage: '',
desc: '',
notes: '',
chats: [{
message: [],
note: '',
name: 'Chat 1',
localLore: []
}],
chatPage: 0,
emotionImages: [],
bias: [],
viewScreen: 'none',
globalLore: [],
chaId: uuidv4(),
type: 'character',
sdData: defaultSdDataFunc(),
utilityBot: false,
customscript: [],
exampleMessage: '',
creatorNotes:'',
systemPrompt:'',
postHistoryInstructions:'',
alternateGreetings:[],
tags:[],
creator:"",
characterVersion: '',
personality:"",
scenario:"",
firstMsgIndex: -1,
replaceGlobalNote: "",
triggerscript: [],
additionalText: ''
}
}
export async function makeGroupImage() {
try {
alertStore.set({
type: 'wait',
msg: `Loading..`
})
const db = get(DataBase)
const charID = get(selectedCharID)
const group = db.characters[charID]
if(group.type !== 'group'){
return
}
const imageUrls = await Promise.all(group.characters.map((v) => {
return getCharImage(findCharacterbyId(v).image, 'plain')
}))
const canvas = document.createElement("canvas");
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext("2d");
// Load the images
const images = [];
let loadedImages = 0;
await Promise.all(
imageUrls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.crossOrigin="anonymous"
img.onload = () => {
images.push(img);
resolve();
};
img.src = url;
})
)
);
// Calculate dimensions and draw the grid
const numImages = images.length;
const numCols = Math.ceil(Math.sqrt(images.length));
const numRows = Math.ceil(images.length / numCols);
const cellWidth = canvas.width / numCols;
const cellHeight = canvas.height / numRows;
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const index = row * numCols + col;
if (index >= numImages) break;
ctx.drawImage(
images[index],
col * cellWidth,
row * cellHeight,
cellWidth,
cellHeight
);
}
}
// Return the image URI
const uri = canvas.toDataURL()
canvas.remove()
db.characters[charID].image = await saveImage(dataURLtoBuffer(uri));
setDatabase(db)
alertStore.set({
type: 'none',
msg: ''
})
} catch (error) {
alertError(`${error}`)
}
}
function dataURLtoBuffer(string:string){
const regex = /^data:.+\/(.+);base64,(.*)$/;
const matches = string.match(regex);
const ext = matches[1];
const data = matches[2];
return Buffer.from(data, 'base64');
}
export async function addDefaultCharacters() {
const imgs = [fetch('/sample/rika.png'),fetch('/sample/yuzu.png')]
alertStore.set({
type: 'wait',
msg: `Loading Sample bots...`
})
for(const img of imgs){
const imgBuffer = await (await img).arrayBuffer()
const readed = PngChunk.read(Buffer.from(imgBuffer), ["risuai"])?.risuai
await sleep(10)
const va = decodeMsgpack(Buffer.from(readed,'base64')) as any
if(va.type !== 101){
alertError(language.errors.noData)
return
}
let char:character = va.data
let db = get(DataBase)
if(char.emotionImages && char.emotionImages.length > 0){
for(let i=0;i<char.emotionImages.length;i++){
await sleep(10)
const imgp = await saveImage(char.emotionImages[i][1] as any)
char.emotionImages[i][1] = imgp
}
}
char.chats = [{
message: [],
note: '',
name: 'Chat 1',
localLore: []
}]
if(checkNullish(char.sdData)){
char.sdData = defaultSdDataFunc()
}
char.chatPage = 0
char.image = await saveImage(await reencodeImage(Buffer.from(imgBuffer)))
char.chaId = uuidv4()
db.characters.push(characterFormatUpdate(char))
setDatabase(db)
}
alertStore.set({
type: 'none',
msg: ''
})
}
export async function removeChar(index:number,name:string, type:'normal'|'permanent'|'permanentForce' = 'normal'){
const db = get(DataBase)
if(type !== 'permanentForce'){
const conf = await alertConfirm(language.removeConfirm + name)
if(!conf){
return
}
const conf2 = await alertConfirm(language.removeConfirm2 + name)
if(!conf2){
return
}
}
let chars = db.characters
if(type === 'normal'){
chars[index].trashTime = Date.now()
}
else{
chars.splice(index, 1)
}
checkCharOrder()
db.characters = chars
setDatabase(db)
selectedCharID.set(-1)
}
export async function addCharacter(arg:{
reseter?:()=>any,
} = {}){
MobileGUIStack.set(100)
const reseter = arg.reseter ?? (() => {})
const r = await alertAddCharacter()
if(r === 'importFromRealm'){
selectedCharID.set(-1)
OpenRealmStore.set(true)
MobileGUIStack.set(0)
return
}
reseter();
switch(r){
case 'createfromScratch':
createNewCharacter()
break
case 'createGroup':
createNewGroup()
break
case 'importCharacter':
await importCharacter()
break
default:
MobileGUIStack.set(1)
return
}
let db = get(DataBase)
if(db.characters[db.characters.length-1]){
changeChar(db.characters.length-1)
}
MobileGUIStack.set(1)
}
export function changeChar(index: number, arg:{
reseter?:()=>any,
} = {}) {
const reseter = arg.reseter ?? (() => {})
if(get(doingChat)){
return
}
reseter();
characterFormatUpdate(index, {
updateInteraction: true,
});
selectedCharID.set(index);
}