feat: Add new prompt editor

pull/477/head
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__ __pycache__
/lobe_theme_config.json /lobe_theme_config.json
bun.lockb 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": { "prompt": {
"area": {
"object": "Object Selection",
"attribute": "Attribute Selection",
"tag": "Tag Selection"
},
"load": "Load Prompt", "load": "Load Prompt",
"set": "Set Prompt", "set": "Set Prompt",
"negative": "Negative", "negative": "Negative",

View File

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

View File

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

View File

@ -10,10 +10,10 @@ class LobeLogClass:
def debug(self, message: str): def debug(self, message: str):
if self.logging_enabled: if self.logging_enabled:
print(f"[Lobe:DEBUG]: {message}") print(f"[DEBUG] 🤯 LobeTheme: {message}")
def info(self, message: str): def info(self, message: str):
print(f"[Lobe]: {message}") print(f"🤯 LobeTheme: {message}")
LobeLog = LobeLogClass() 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.api import LobeApi
from scripts.lib.config import LobeConfig from scripts.lib.config import LobeConfig
from scripts.lib.package import LobePackage from scripts.lib.package import LobePackage
from scripts.lib.prompt import LobePrompt
from scripts.lib.locale import LobeLocale from scripts.lib.locale import LobeLocale
def init_lobe(_: Any, app: FastAPI, **kwargs): def init_lobe(_: Any, app: FastAPI, **kwargs):
LobeLog.info("Initializing Lobe") LobeLog.info("Initializing...")
package = LobePackage() package = LobePackage()
prompt = LobePrompt()
locale = LobeLocale() locale = LobeLocale()
config = LobeConfig() config = LobeConfig()
api = LobeApi(config, package, locale) api = LobeApi(config, package, prompt, locale)
api.create_api_route(app) api.create_api_route(app)

View File

@ -1,6 +1,7 @@
import { consola } from 'consola'; import { consola } from 'consola';
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import TagList, { PromptType, TagItem } from './TagList'; import TagList, { PromptType, TagItem } from './TagList';
import { useStyles } from './style'; import { useStyles } from './style';
@ -51,24 +52,26 @@ const Prompt = memo<PromptProps>(({ type }) => {
return ( return (
<div className={styles.promptView}> <div className={styles.promptView}>
<TagList setTags={setTags} setValue={setCurrentValue} tags={tags} type={type} /> <TagList setTags={setTags} setValue={setCurrentValue} tags={tags} type={type} />
<div className={styles.buttonGroup}> <Flexbox gap={8} horizontal>
<button <button
className="lg secondary gradio-button tool svelte-1ipelgc" className="secondary gradio-button"
onClick={getValue} onClick={getValue}
style={{ flex: 1, height: 36 }}
title={t('prompt.load')} title={t('prompt.load')}
type="button" type="button"
> >
🔄 🔄
</button> </button>
<button <button
className="lg secondary gradio-button tool svelte-1ipelgc" className="secondary gradio-button"
onClick={setValue} onClick={setValue}
style={{ flex: 1, height: 36 }}
title={t('prompt.set')} title={t('prompt.set')}
type="button" type="button"
> >
</button> </button>
</div> </Flexbox>
</div> </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 { memo } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import Prompt from './Prompt';
const PromptEditor = memo(() => { const PromptEditor = memo(() => {
const { styles } = useStyles(); const setting = useAppStore(selectors.currentSetting, isEqual);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={styles.view}> <Flexbox gap={16}>
<span style={{ marginBottom: -10 }}>{t('prompt.positive')}</span> {setting.promptEditor && (
<Prompt type="positive" /> <>
<span style={{ marginBottom: -10 }}>{t('prompt.negative')}</span> <span style={{ marginBottom: -10 }}>{t('prompt.positive')}</span>
<Prompt type="negative" /> <Prompt type="positive" />
</div> <span style={{ marginBottom: -10 }}>{t('prompt.negative')}</span>
<Prompt type="negative" />
</>
)}
<PromptPicker />
</Flexbox>
); );
}); });

View File

@ -1,19 +1,9 @@
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css }) => ({ export const useStyles = createStyles(({ css }) => ({
buttonGroup: css`
display: flex;
gap: 8px;
`,
promptView: css` promptView: css`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; 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(',', ', '); .replaceAll(',', ', ');
return Converter.convertStr2Array(newItem).join(', '); 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 { DraggablePanelBody } from '@lobehub/ui';
import { Segmented } from 'antd';
import { useTheme } from 'antd-style';
import { consola } from 'consola'; import { consola } from 'consola';
import isEqual from 'fast-deep-equal'; import { memo, useEffect, useRef, useState } from 'react';
import { memo, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { PromptEditor } from '@/components'; import { PromptEditor } from '@/components';
import { selectors, useAppStore } from '@/store';
import { type DivProps } from '@/types'; import { type DivProps } from '@/types';
const Inner = memo<DivProps>(() => { enum Tabs {
const setting = useAppStore(selectors.currentSetting, isEqual); Prompt = 'prompt',
const sidebarReference = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
try { try {
const sidebar = gradioApp().querySelector('#quicksettings'); const sidebar = gradioApp().querySelector('#quicksettings');
@ -23,8 +31,20 @@ const Inner = memo<DivProps>(() => {
return ( return (
<DraggablePanelBody> <DraggablePanelBody>
{setting.promptEditor && <PromptEditor />} <Flexbox gap={16}>
<div ref={sidebarReference} /> <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> </DraggablePanelBody>
); );
}); });

View File

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

View File

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

View File

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

View File

@ -32,6 +32,31 @@ export const getVersion = async(): Promise<string> => {
return data.version; 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']> => { export const getLocaleOptions = async(): Promise<SelectProps['options']> => {
const res = await fetch('/lobe/locales/options'); const res = await fetch('/lobe/locales/options');
const data = (await res.json()) as SelectProps['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'; import type { Store } from './store';
const currentSetting = (s: Store) => ({ ...DEFAULT_SETTING, ...s.setting }); const currentSetting = (s: Store) => ({ ...DEFAULT_SETTING, ...s.setting });
const currentLanguage = (s: Store) => currentSetting(s).i18n;
const currentTab = (s: Store) => s.currentTab; const currentTab = (s: Store) => s.currentTab;
const themeMode = (s: Store) => s.themeMode; const themeMode = (s: Store) => s.themeMode;
export const selectors = { export const selectors = {
currentLanguage,
currentSetting, currentSetting,
currentTab, currentTab,
themeMode, themeMode,

View File

@ -2,8 +2,17 @@ import { Theme, css } from 'antd-style';
import { readableColor } from 'polished'; import { readableColor } from 'polished';
export default (token: Theme) => css` 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 { ::-webkit-scrollbar {
display: none;
width: 0; width: 0;
height: 0; height: 0;
} }