feat: Add new prompt editor

This commit is contained in:
canisminor1990 2023-11-29 16:26:45 +08:00
parent d2ec3745f0
commit 03e67ba5b8
21 changed files with 6302 additions and 97 deletions

1
.gitignore vendored
View File

@ -43,3 +43,4 @@ test-output
__pycache__
/lobe_theme_config.json
bun.lockb
.env

5914
data/prompt.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -29,6 +29,11 @@
}
},
"prompt": {
"area": {
"object": "Object Selection",
"attribute": "Attribute Selection",
"tag": "Tag Selection"
},
"load": "Load Prompt",
"set": "Set Prompt",
"negative": "Negative",

View File

@ -29,6 +29,11 @@
}
},
"prompt": {
"area": {
"object": "对象选择区",
"attribute": "属性选择区",
"tag": "标签选择区"
},
"load": "加载提示",
"set": "设置提示",
"negative": "否定",

View File

@ -5,12 +5,14 @@ from fastapi import FastAPI, Response, Request
from scripts.lib.config import LobeConfig
from scripts.lib.package import LobePackage
from scripts.lib.prompt import LobePrompt
from scripts.lib.locale import LobeLocale
from scripts.lib.lobe_log import LobeLog
class LobeApi:
def __init__(self, config: LobeConfig, package: LobePackage, locale: LobeLocale):
def __init__(self, config: LobeConfig, package: LobePackage, prompt:LobePrompt, locale: LobeLocale):
self.package = package
self.prompt = prompt
self.config = config
self.locale = locale
pass
@ -25,6 +27,14 @@ class LobeApi:
return Response(content=self.package.json(), media_type="application/json", status_code=404)
return Response(content=self.package.json(), media_type="application/json", status_code=200)
@app.get("/lobe/prompt")
async def lobe_prompt_get():
LobeLog.debug("lobe_prompt_get")
if self.prompt.is_empty():
return Response(content=self.prompt.json(), media_type="application/json", status_code=404)
return Response(content=self.prompt.json(), media_type="application/json", status_code=200)
@app.get("/lobe/locales/{lng}")
async def lobe_locale_get(lng: str):
LobeLog.debug(f"lobe_locale_get: {lng}")

View File

@ -10,10 +10,10 @@ class LobeLogClass:
def debug(self, message: str):
if self.logging_enabled:
print(f"[Lobe:DEBUG]: {message}")
print(f"[DEBUG] 🤯 LobeTheme: {message}")
def info(self, message: str):
print(f"[Lobe]: {message}")
print(f"🤯 LobeTheme: {message}")
LobeLog = LobeLogClass()

40
scripts/lib/prompt.py Normal file
View File

@ -0,0 +1,40 @@
import json
import os
from pathlib import Path
from scripts.lib.lobe_log import LobeLog
EXTENSION_FOLDER = Path(__file__).parent.parent.parent
PACKAGE_FILENAME = Path(EXTENSION_FOLDER, "data/prompt.json")
LobeLog.debug(f"EXTENSION_FOLDER: {EXTENSION_FOLDER}")
LobeLog.debug(f"PACKAGE_FILENAME: {PACKAGE_FILENAME}")
class LobePrompt:
def __init__(self):
self.prompt_file = PACKAGE_FILENAME
self.prompt = None
self.load_prompt()
def load_prompt(self):
if os.path.exists(self.prompt_file):
LobeLog.debug(f"Loading prompt from prompt.json")
with open(self.prompt_file, 'r') as f:
self.prompt = json.load(f)
else:
LobeLog.debug(f"Prompt file not found")
self.prompt = {"error": "Prompt file not found"}
def is_empty(self):
return "empty" in self.prompt and self.prompt['empty']
def json(self):
return json.dumps(self.prompt)
@staticmethod
def default():
# default prompt is handled from client side @see src/store/index.tsx
return {'empty': True}

View File

@ -11,15 +11,17 @@ from scripts.lib.lobe_log import LobeLog
from scripts.lib.api import LobeApi
from scripts.lib.config import LobeConfig
from scripts.lib.package import LobePackage
from scripts.lib.prompt import LobePrompt
from scripts.lib.locale import LobeLocale
def init_lobe(_: Any, app: FastAPI, **kwargs):
LobeLog.info("Initializing Lobe")
LobeLog.info("Initializing...")
package = LobePackage()
prompt = LobePrompt()
locale = LobeLocale()
config = LobeConfig()
api = LobeApi(config, package, locale)
api = LobeApi(config, package, prompt, locale)
api.create_api_route(app)

View File

@ -1,6 +1,7 @@
import { consola } from 'consola';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import TagList, { PromptType, TagItem } from './TagList';
import { useStyles } from './style';
@ -51,24 +52,26 @@ const Prompt = memo<PromptProps>(({ type }) => {
return (
<div className={styles.promptView}>
<TagList setTags={setTags} setValue={setCurrentValue} tags={tags} type={type} />
<div className={styles.buttonGroup}>
<Flexbox gap={8} horizontal>
<button
className="lg secondary gradio-button tool svelte-1ipelgc"
className="secondary gradio-button"
onClick={getValue}
style={{ flex: 1, height: 36 }}
title={t('prompt.load')}
type="button"
>
🔄
</button>
<button
className="lg secondary gradio-button tool svelte-1ipelgc"
className="secondary gradio-button"
onClick={setValue}
style={{ flex: 1, height: 36 }}
title={t('prompt.set')}
type="button"
>
</button>
</div>
</Flexbox>
</div>
);
});

View File

@ -0,0 +1,154 @@
import { Button, Skeleton } from 'antd';
import { consola } from 'consola';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import useSWR from 'swr';
import { TagItem } from '@/components/PromptEditor/TagList';
import { formatPrompt } from '@/components/PromptEditor/utils';
import { selectors, useAppStore } from '@/store';
import { getPrompt } from '@/store/api';
const ID = `[id$='2img_prompt'] textarea`;
const PromptPicker = memo(() => {
const { data, isLoading } = useSWR('prompt', getPrompt);
const [tags, setTags] = useState<TagItem[]>([]);
const [activeObject, setActiveObject] = useState<string>();
const [activeAttribute, setActiveAttribute] = useState<string>();
const i18n = useAppStore(selectors.currentLanguage);
const { t } = useTranslation();
const isCN = i18n === 'zh_CN' || i18n === 'zh_HK';
const getValue = useCallback(() => {
try {
const textarea = get_uiCurrentTabContent().querySelector(ID) as HTMLTextAreaElement;
const data = formatPrompt(textarea.value);
if (textarea) setTags(data);
return data;
} catch (error) {
consola.error('🤯 [prompt]', error);
}
}, []);
const setValue = useCallback((currentTags: TagItem[]) => {
try {
const newValue = currentTags.map((t) => t.text).join(', ');
const textarea = get_uiCurrentTabContent().querySelector(ID) as HTMLTextAreaElement;
if (textarea) textarea.value = newValue;
updateInput(textarea);
} catch (error) {
consola.error('🤯 [prompt]', error);
}
}, []);
const handleTagUpdate = useCallback((tag: TagItem) => {
let currentTags = getValue() || [];
console.log(currentTags);
const hasTag = currentTags.some(
(t) => t.text.toLowerCase() === tag.text.toLowerCase() || t.id === tag.id,
);
if (hasTag) {
currentTags = currentTags.filter(
(t) => t.text.toLowerCase() !== tag.text.toLowerCase() && t.id !== tag.id,
);
} else {
currentTags = [...currentTags, tag].filter(Boolean);
}
setTags(currentTags);
setValue(currentTags);
}, []);
useEffect(() => {
getValue();
if (!data || activeObject || activeAttribute) return;
const defaultActiveObject = Object.keys(data)[0];
setActiveObject(defaultActiveObject);
const defaultActiveAttribute = Object.keys(data[defaultActiveObject].children)[0];
setActiveAttribute(defaultActiveAttribute);
}, [data, activeObject, activeAttribute]);
if (isLoading || !data) return <Skeleton active />;
return (
<>
<span style={{ marginBottom: -10 }}>{t('prompt.area.object')}</span>
<Flexbox gap={4} horizontal style={{ flexWrap: 'wrap' }}>
{Object.entries(data).map(([key, value], index) => {
const name = isCN ? value.langName : value.name;
const isActive = activeObject ? activeObject === key : index === 0;
return (
<Button
key={key}
onClick={() => {
setActiveObject(key);
setActiveAttribute(Object.keys(data[key].children)[0]);
}}
size={'small'}
style={{ flex: 1 }}
type={isActive ? 'primary' : 'default'}
>
{name}
</Button>
);
})}
</Flexbox>
<span style={{ marginBottom: -10 }}>{t('prompt.area.attribute')}</span>
<Flexbox gap={4} horizontal style={{ flexWrap: 'wrap' }}>
{activeObject &&
Object.entries(data[activeObject].children).map(([key, value], index) => {
const name = isCN ? value.langName : value.name;
const isActive = activeAttribute ? activeAttribute === key : index === 0;
return (
<Button
key={key}
onClick={() => setActiveAttribute(key)}
size={'small'}
style={{ flex: 1 }}
type={isActive ? 'primary' : 'default'}
>
{name}
</Button>
);
})}
</Flexbox>
<span style={{ marginBottom: -10 }}>{t('prompt.area.tag')}</span>
<Flexbox gap={4} horizontal style={{ flexWrap: 'wrap' }}>
{activeObject &&
activeAttribute &&
Object.entries(data[activeObject].children[activeAttribute].children).map(
([key, value]) => {
const isActive = tags.some(
(tag) => tag.text.toLowerCase() === value.name.toLowerCase(),
);
return (
<Button
key={key}
onClick={() => handleTagUpdate({ id: key, text: value.name })}
size={'small'}
style={isCN ? { flex: 1, height: 36 } : { flex: 1 }}
type={isActive ? 'primary' : 'dashed'}
>
{isCN ? (
<Flexbox gap={2}>
<div style={{ fontSize: 12, fontWeight: 600, lineHeight: 1 }}>
{value.langName}
</div>
<div style={{ fontSize: 12, lineHeight: 1, opacity: 0.75 }}>{value.name}</div>
</Flexbox>
) : (
value.name
)}
</Button>
);
},
)}
</Flexbox>
</>
);
});
export default PromptPicker;

View File

@ -1,20 +1,28 @@
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useStyles } from '@/components/PromptEditor/style';
import PromptPicker from '@/components/PromptEditor/PromptPicker';
import { selectors, useAppStore } from '@/store';
import Prompt from './Prompt';
const PromptEditor = memo(() => {
const { styles } = useStyles();
const setting = useAppStore(selectors.currentSetting, isEqual);
const { t } = useTranslation();
return (
<div className={styles.view}>
<span style={{ marginBottom: -10 }}>{t('prompt.positive')}</span>
<Prompt type="positive" />
<span style={{ marginBottom: -10 }}>{t('prompt.negative')}</span>
<Prompt type="negative" />
</div>
<Flexbox gap={16}>
{setting.promptEditor && (
<>
<span style={{ marginBottom: -10 }}>{t('prompt.positive')}</span>
<Prompt type="positive" />
<span style={{ marginBottom: -10 }}>{t('prompt.negative')}</span>
<Prompt type="negative" />
</>
)}
<PromptPicker />
</Flexbox>
);
});

View File

@ -1,19 +1,9 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css }) => ({
buttonGroup: css`
display: flex;
gap: 8px;
`,
promptView: css`
display: flex;
flex-direction: column;
gap: 8px;
`,
view: css`
display: flex;
flex-direction: column;
gap: 1em;
margin-bottom: 1em;
`,
}));

View File

@ -29,5 +29,5 @@ export const formatPrompt = (value: string) => {
.replaceAll(',', ', ');
return Converter.convertStr2Array(newItem).join(', ');
});
return textArray.map((tag) => genTagType({ id: tag, text: tag }));
return textArray.map((tag) => genTagType({ id: tag.trim(), text: tag.trim() }));
};

View File

@ -1,16 +1,24 @@
import { DraggablePanelBody } from '@lobehub/ui';
import { Segmented } from 'antd';
import { useTheme } from 'antd-style';
import { consola } from 'consola';
import isEqual from 'fast-deep-equal';
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { PromptEditor } from '@/components';
import { selectors, useAppStore } from '@/store';
import { type DivProps } from '@/types';
const Inner = memo<DivProps>(() => {
const setting = useAppStore(selectors.currentSetting, isEqual);
const sidebarReference = useRef<HTMLDivElement>(null);
enum Tabs {
Prompt = 'prompt',
Setting = 'setting',
}
const Inner = memo<DivProps>(() => {
const theme = useTheme();
const [tab, setTab] = useState<Tabs>(Tabs.Setting);
const sidebarReference = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
try {
const sidebar = gradioApp().querySelector('#quicksettings');
@ -23,8 +31,20 @@ const Inner = memo<DivProps>(() => {
return (
<DraggablePanelBody>
{setting.promptEditor && <PromptEditor />}
<div ref={sidebarReference} />
<Flexbox gap={16}>
<Segmented
block
onChange={(value) => setTab(value as Tabs)}
options={[
{ label: t('sidebar.quickSetting'), value: Tabs.Setting },
{ label: t('setting.promptEditor.title'), value: Tabs.Prompt },
]}
style={{ background: theme.colorBgContainer, width: '100%' }}
value={tab}
/>
<div ref={sidebarReference} style={tab === Tabs.Setting ? {} : { display: 'none' }} />
{tab === Tabs.Prompt && <PromptEditor />}
</Flexbox>
</DraggablePanelBody>
);
});

View File

@ -2,12 +2,12 @@ import { Form } from '@lobehub/ui';
import { Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { Puzzle, TextCursorInput } from 'lucide-react';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Footer from '@/features/Setting/Form/Footer';
import { SettingItemGroup } from '@/features/Setting/Form/types';
import { selectors, useAppStore } from '@/store';
import { WebuiSetting, selectors, useAppStore } from '@/store';
const SettingForm = memo(() => {
const setting = useAppStore(selectors.currentSetting, isEqual);
@ -15,6 +15,11 @@ const SettingForm = memo(() => {
const { t } = useTranslation();
const onFinish = useCallback((value: WebuiSetting) => {
onSetSetting(value);
location.reload();
}, []);
const experimental: SettingItemGroup = useMemo(
() => ({
children: [
@ -61,7 +66,7 @@ const SettingForm = memo(() => {
footer={<Footer />}
initialValues={setting}
items={[experimental, promptTextarea]}
onFinish={onSetSetting}
onFinish={onFinish}
style={{ flex: 1 }}
/>
);

View File

@ -2,12 +2,12 @@ import { Form } from '@lobehub/ui';
import { Segmented, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { Layout, TextCursorInput } from 'lucide-react';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Footer from '@/features/Setting/Form/Footer';
import { SettingItemGroup } from '@/features/Setting/Form/types';
import { selectors, useAppStore } from '@/store';
import { WebuiSetting, selectors, useAppStore } from '@/store';
const SettingForm = memo(() => {
const setting = useAppStore(selectors.currentSetting, isEqual);
@ -15,6 +15,11 @@ const SettingForm = memo(() => {
const { t } = useTranslation();
const onFinish = useCallback((value: WebuiSetting) => {
onSetSetting(value);
location.reload();
}, []);
const layout: SettingItemGroup = useMemo(
() => ({
children: [
@ -73,7 +78,7 @@ const SettingForm = memo(() => {
footer={<Footer />}
initialValues={setting}
items={[layout, promptTextarea]}
onFinish={onSetSetting}
onFinish={onFinish}
style={{ flex: 1 }}
/>
);

View File

@ -2,7 +2,7 @@ import { Form } from '@lobehub/ui';
import { InputNumber, Segmented, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { PanelLeftClose, PanelRightClose } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Footer from '@/features/Setting/Form/Footer';
@ -16,6 +16,11 @@ const SettingForm = memo(() => {
const { t } = useTranslation();
const onFinish = useCallback((value: WebuiSetting) => {
onSetSetting(value);
location.reload();
}, []);
const quickSettingSidebar: SettingItemGroup = useMemo(
() => ({
children: [
@ -132,7 +137,7 @@ const SettingForm = memo(() => {
footer={<Footer />}
initialValues={setting}
items={[quickSettingSidebar, extraNetworkSidebar]}
onFinish={onSetSetting}
onFinish={onFinish}
onValuesChange={(_, v) => setRawSetting(v)}
style={{ flex: 1 }}
/>

View File

@ -32,6 +32,31 @@ export const getVersion = async(): Promise<string> => {
return data.version;
};
interface PromptData {
[key: string]: {
children: {
[key: string]: {
children: {
[key: string]: {
langName: string;
name: string;
};
};
langName: string;
name: string;
};
};
langName: string;
name: string;
};
}
export const getPrompt = async(): Promise<PromptData> => {
const res = await fetch('/lobe/prompt');
const data = (await res.json()) as any;
return data;
};
export const getLocaleOptions = async(): Promise<SelectProps['options']> => {
const res = await fetch('/lobe/locales/options');
const data = (await res.json()) as SelectProps['options'];

View File

@ -2,9 +2,12 @@ import { DEFAULT_SETTING } from './initialState';
import type { Store } from './store';
const currentSetting = (s: Store) => ({ ...DEFAULT_SETTING, ...s.setting });
const currentLanguage = (s: Store) => currentSetting(s).i18n;
const currentTab = (s: Store) => s.currentTab;
const themeMode = (s: Store) => s.themeMode;
export const selectors = {
currentLanguage,
currentSetting,
currentTab,
themeMode,

View File

@ -2,8 +2,17 @@ import { Theme, css } from 'antd-style';
import { readableColor } from 'polished';
export default (token: Theme) => css`
body {
html,
body,
#__next,
.ant-app {
position: relative;
overscroll-behavior: none;
height: 100% !important;
min-height: 100% !important;
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}