├── .gitignore
├── .yarnrc.yml
├── README.md
├── app.config.ts
├── app.vue
├── components
├── PredictionPoller.vue
└── ui
│ ├── Canvas.vue
│ ├── LeftPanel.vue
│ ├── TopPanel.vue
│ └── form
│ ├── Create.vue
│ ├── Finetune.vue
│ └── VersionPicker.vue
├── nuxt.config.ts
├── package.json
├── pages
└── index.vue
├── prettier.config.cjs
├── public
├── cover.jpg
├── favicon.ico
└── replicate-logo.svg
├── server
├── api
│ ├── model.post.js
│ ├── prediction.get.js
│ ├── prediction.post.js
│ ├── search.get.js
│ └── training.post.js
└── tsconfig.json
├── stores
├── prediction.js
└── version.js
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # Nuxt dev/build outputs
2 | .output
3 | .data
4 | .nuxt
5 | .nitro
6 | .cache
7 | dist
8 |
9 | # Node dependencies
10 | node_modules
11 |
12 | # Logs
13 | logs
14 | *.log
15 |
16 | # Misc
17 | .DS_Store
18 | .fleet
19 | .idea
20 | .yarn
21 |
22 | # Local env files
23 | .env
24 | .env.*
25 | !.env.example
26 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ReFlux
2 |
3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
4 |
5 | ## Setup
6 |
7 | Make sure to install the dependencies:
8 |
9 | ```bash
10 | # npm
11 | npm install
12 |
13 | # pnpm
14 | pnpm install
15 |
16 | # yarn
17 | yarn install
18 |
19 | # bun
20 | bun install
21 | ```
22 |
23 | ## Development Server
24 |
25 | Start the development server on `http://localhost:3000`:
26 |
27 | ```bash
28 | # npm
29 | npm run dev
30 |
31 | # pnpm
32 | pnpm run dev
33 |
34 | # yarn
35 | yarn dev
36 |
37 | # bun
38 | bun run dev
39 | ```
40 |
41 | ## Production
42 |
43 | Build the application for production:
44 |
45 | ```bash
46 | # npm
47 | npm run build
48 |
49 | # pnpm
50 | pnpm run build
51 |
52 | # yarn
53 | yarn build
54 |
55 | # bun
56 | bun run build
57 | ```
58 |
59 | Locally preview production build:
60 |
61 | ```bash
62 | # npm
63 | npm run preview
64 |
65 | # pnpm
66 | pnpm run preview
67 |
68 | # yarn
69 | yarn preview
70 |
71 | # bun
72 | bun run preview
73 | ```
74 |
75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
76 |
--------------------------------------------------------------------------------
/app.config.ts:
--------------------------------------------------------------------------------
1 | export default defineAppConfig({
2 | ui: {
3 | primary: 'yellow',
4 | gray: 'stone'
5 | }
6 | })
7 |
--------------------------------------------------------------------------------
/app.vue:
--------------------------------------------------------------------------------
1 |
2 | .relative.flex.flex-col.min-h-screen
3 | nuxt-page
4 |
5 |
6 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/components/PredictionPoller.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
47 |
--------------------------------------------------------------------------------
/components/ui/Canvas.vue:
--------------------------------------------------------------------------------
1 |
2 | .w-full.height-full.overflow-hidden(@contextmenu.prevent="onContextMenu")
3 | #canvas(
4 | :style="containerStyles"
5 | ref="canvas"
6 | )
7 | u-context-menu(
8 | v-model="isContextMenuOpen"
9 | :virtual-element="virtualElement"
10 | :ui="{ transition: { enterActiveClass: 'transition-none', enterFromClass: 'transition-none', enterToClass: 'transition-none', leaveActiveClass: 'transition-none', leaveFromClass: 'transition-none', leaveToClass: 'transition-none' } }"
11 | )
12 | u-vertical-navigation(
13 | :links="contextMenuLinks"
14 | :ui="{ size: 'text-xs', font: 'font-normal' }"
15 | )
16 |
17 |
18 |
1160 |
1161 |
1162 |
--------------------------------------------------------------------------------
/components/ui/LeftPanel.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex-shrink-0.overflow-y-auto.w-80.border-r
3 | .p-2
4 | //- Hide for now
5 | u-tabs(
6 | :items="tab_items"
7 | )
8 | template(#create="{ item }")
9 | ui-form-create
10 | template(#finetune="{ item }")
11 | ui-form-finetune
12 |
13 |
14 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/components/ui/TopPanel.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex.flex-row.items-center.w-full.h-14.border-b
3 | .flex.items-center.w-80.px-2.font-thin.text-3xl
4 | a(
5 | href="https://replicate.com/?utm_source=project&utm_campaign=reflux"
6 | target="_new"
7 | )
8 | img.h-8.pl-1.py-1.pr-3.mr-3.border-r(
9 | src="/replicate-logo.svg"
10 | )
11 | span.animate-text
12 | | Re
13 | span.plicate plicate
14 | span.font-medium.inline-block
15 | | Flux
16 | u-badge.mx-1.mt-1.float-right(
17 | class="-rotate-6"
18 | color="red"
19 | variant="solid"
20 | ) Beta
21 |
22 | //- Control buttons
23 | .flex-grow.flex.gap-2
24 | u-button(
25 | @click="tool = 'V'"
26 | :color="tool === 'V' ? 'black' : 'white'"
27 | icon="i-heroicons-cursor-arrow-rays"
28 | size="xs"
29 | )
30 | | Pointer
31 | u-kbd.ml-1 V
32 | u-button(
33 | @click="tool = 'H'"
34 | :color="tool === 'H' ? 'black' : 'white'"
35 | icon="i-heroicons-hand-raised"
36 | size="xs"
37 | )
38 | | Drag
39 | u-kbd.ml-1 H
40 | u-button(
41 | v-if="false"
42 | @click=""
43 | color="white"
44 | icon="i-heroicons-pencil"
45 | size="xs"
46 | )
47 | | Sketch
48 | u-kbd.ml-1 S
49 | u-button(
50 | v-if="false"
51 | @click=""
52 | color="white"
53 | icon="i-heroicons-chat-bubble-bottom-center-text"
54 | size="xs"
55 | )
56 | | Text
57 | u-kbd.ml-1 T
58 |
59 | //- Right hand stuff
60 | .flex.gap-2.px-2
61 | .text-sm.font-light.content-center
62 | | Replicate
63 | a.underline.underline-offset-4(
64 | class="decoration-[0.5px] hover:decoration-2"
65 | href="https://replicate.com/account/api-tokens"
66 | target="_new"
67 | ) API token
68 | | :
69 | u-input.w-64(
70 | v-model="replicate_api_token"
71 | type="password"
72 | icon="i-heroicons-key"
73 | placeholder="Replicate API token..."
74 | trailing
75 | )
76 | u-button(
77 | @click="openCode"
78 | color="white"
79 | icon="i-heroicons-code-bracket"
80 | size="xs"
81 | ) Code
82 |
83 |
84 |
122 |
123 |
140 |
--------------------------------------------------------------------------------
/components/ui/form/Create.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex.flex-col.gap-y-4
3 |
4 | //- Models
5 | u-form-group(
6 | label="Flux fine-tunes"
7 | )
8 | ui-form-version-picker
9 | u-button.mr-2.mt-2(
10 | v-for="(trigger, i) in trigger_words"
11 | :key="`trigger-word-${i}`"
12 | @click="prompt += ' ' + trigger.word"
13 | color="white"
14 | size="2xs"
15 | block
16 | )
17 | .flex-grow.text-left.font-light.break-all {{ trigger.name }}
18 | u-divider.mx-1.h-4(orientation="vertical")
19 | | {{ trigger.word }}
20 | u-divider.mx-1.h-4(orientation="vertical")
21 | u-icon.w-4(
22 | @click.stop="versions = versions.filter((version) => version !== trigger.version)"
23 | name="i-heroicons-trash"
24 | )
25 | u-button.mr-2.mt-2(
26 | v-for="(version, i) in hf_versions"
27 | :key="`hf_version-${i}`"
28 | color="white"
29 | size="2xs"
30 | block
31 | )
32 | .flex-grow.text-left.font-light.break-all 🤗 {{ version }}
33 | u-divider.mx-1.h-4(orientation="vertical")
34 | u-icon.w-4(
35 | @click.stop="hf_versions = hf_versions.filter((v) => v !== version)"
36 | name="i-heroicons-trash"
37 | )
38 | //- Merge
39 | u-form-group(
40 | :ui="{ container: '', hint: 'text-gray-500 dark:text-gray-400 flex align-center' }"
41 | :help="[...versions, ...hf_versions].length !== 2 ? 'You can only merge two fine-tunes.' : ''"
42 | label="Merge into same image"
43 | name="merge"
44 | )
45 | template(#hint)
46 | u-toggle(
47 | v-model="merge"
48 | :disabled="[...versions, ...hf_versions].length !== 2"
49 | size="lg"
50 | )
51 | //- Prompt
52 | u-form-group(
53 | label="Prompt"
54 | name="prompt"
55 | )
56 | u-textarea(
57 | v-model="prompt"
58 | placeholder="Write a prompt..."
59 | autoresize
60 | )
61 | //- Aspect ratio
62 | u-form-group(
63 | label="Aspect ratio"
64 | name="aspect_ratio"
65 | )
66 | u-select-menu(
67 | v-model="aspect_ratio"
68 | :options="aspect_ratio_options"
69 | )
70 | //- LoRA scale
71 | u-form-group(
72 | label="LoRA scale"
73 | name="lora_scale"
74 | )
75 | .flex.items-center.gap-x-4
76 | u-range.flex-grow(
77 | v-model="lora_scale"
78 | :min="-1"
79 | :max="1"
80 | :step="0.01"
81 | )
82 | u-input.w-24(
83 | v-model="lora_scale"
84 | type="number"
85 | min="-1"
86 | max="1"
87 | step="0.01"
88 | )
89 | //- Inference steps
90 | u-form-group(
91 | label="Steps"
92 | name="num_inference_steps"
93 | )
94 | .flex.items-center.gap-x-4
95 | u-range.flex-grow(
96 | v-model="num_inference_steps"
97 | :min="1"
98 | :max="50"
99 | :step="1"
100 | )
101 | u-input.w-24(
102 | v-model="num_inference_steps"
103 | type="number"
104 | min="1"
105 | max="50"
106 | step="1"
107 | )
108 | //- Guidance scale
109 | u-form-group(
110 | label="Gudance"
111 | name="guidance_scale"
112 | )
113 | .flex.items-center.gap-x-4
114 | u-range.flex-grow(
115 | v-model="guidance_scale"
116 | :min="0"
117 | :max="10"
118 | :step="0.01"
119 | )
120 | u-input.w-24(
121 | v-model="guidance_scale"
122 | type="number"
123 | min="0"
124 | max="10"
125 | step="0.01"
126 | )
127 | //- Seed
128 | u-form-group(
129 | label="Seed"
130 | name="seed"
131 | )
132 | u-input(
133 | v-model="seed"
134 | placeholder="Seed..."
135 | type="number"
136 | )
137 | //- Output format
138 | u-form-group(
139 | label="Output format"
140 | name="output_format"
141 | )
142 | u-select-menu(
143 | v-model="output_format"
144 | :options="output_format_options"
145 | )
146 | //- Output quality
147 | u-form-group(
148 | label="Output quality"
149 | name="output_quality"
150 | )
151 | .flex.items-center.gap-x-4
152 | u-range.flex-grow(
153 | v-model="output_quality"
154 | :min="0"
155 | :max="100"
156 | :step="1"
157 | )
158 | u-input.w-24(
159 | v-model="output_quality"
160 | type="number"
161 | min="0"
162 | max="100"
163 | step="1"
164 | )
165 | //- Number of outputs (conditional)
166 | u-form-group(
167 | v-if="[...versions, ...hf_versions].length <= 1 || merge"
168 | label="Number of outputs"
169 | name="num_outputs"
170 | )
171 | .flex.items-center.gap-x-4
172 | u-range.flex-grow(
173 | v-model="num_outputs"
174 | :min="1"
175 | :max="4"
176 | :step="1"
177 | )
178 | u-input.w-24(
179 | v-model="num_outputs"
180 | type="number"
181 | min="1"
182 | max="4"
183 | step="1"
184 | )
185 | //- Submit
186 | u-button(
187 | v-if="replicate_api_token"
188 | @click="submit"
189 | :disabled="loading || !replicate_api_token || [...versions, ...hf_versions].length <= 0"
190 | :loading="loading"
191 | size="xl"
192 | block
193 | ) Create
194 |
195 | u-alert.dark(
196 | v-if="!replicate_api_token"
197 | color="primary"
198 | variant="solid"
199 | description="Please add your Replicate API token in the top right."
200 | )
201 |
202 |
203 |
286 |
287 |
288 |
--------------------------------------------------------------------------------
/components/ui/form/Finetune.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex.flex-col.gap-y-4
3 |
4 | //- Hidden input
5 | input(
6 | @change="onFileSelected"
7 | type="file"
8 | ref="file"
9 | accept="image/*"
10 | multiple="false"
11 | )
12 |
13 | //- Subject
14 | u-form-group(
15 | label="Subject"
16 | name="subject"
17 | hint="Just 1 image!"
18 | )
19 | img.rounded.mb-3(
20 | v-if="subject"
21 | :src="subject"
22 | )
23 | u-button(
24 | v-if="subject"
25 | @click="subject = null"
26 | icon="i-heroicons-trash"
27 | color="white"
28 | block
29 | )
30 | u-button(
31 | v-else
32 | @click="onClickUpload"
33 | icon="i-heroicons-arrow-up-tray"
34 | color="white"
35 | block
36 | trailing
37 | ) Click to Upload
38 | //- Prompt
39 | u-form-group(
40 | label="Describe the subject"
41 | name="prompt"
42 | )
43 | u-textarea(
44 | v-model="prompt"
45 | placeholder="Describe the subject. Include e.g. clothes and hairstyle."
46 | autoresize
47 | )
48 | //- Destination
49 | u-form-group(
50 | label="Fine-tune name"
51 | name="name"
52 | )
53 | u-input(
54 | v-model="name"
55 | placeholder="Fine-tune model name"
56 | )
57 | //- Trigger word
58 | u-form-group(
59 | label="Trigger word"
60 | name="trigger_word"
61 | )
62 | u-input(
63 | v-model="trigger_word"
64 | placeholder="Fine-tune trigger word"
65 | )
66 | //- Visibility
67 | u-form-group(
68 | :ui="{ container: '', hint: 'text-gray-500 dark:text-gray-400 flex align-center' }"
69 | help="Will this fine-tune be visible on Replicate?"
70 | label="Public"
71 | name="visibility"
72 | )
73 | template(#hint)
74 | u-toggle(
75 | v-model="visibility"
76 | size="lg"
77 | )
78 | //- Submit
79 | u-button(
80 | v-if="replicate_api_token"
81 | @click="submit"
82 | :disabled="loading || !replicate_api_token || !subject || !prompt || !name || !trigger_word"
83 | :loading="loading"
84 | size="xl"
85 | block
86 | ) Create
87 |
88 | u-alert.dark(
89 | v-if="!replicate_api_token"
90 | color="primary"
91 | variant="solid"
92 | description="Please add your Replicate API token in the top right."
93 | )
94 |
95 | //- Trainings
96 | template(v-if="trainings")
97 | u-divider(label="Your fine-tunes")
98 | u-accordion(
99 | :items="training_items"
100 | color="white"
101 | variant="solid"
102 | size="lg"
103 | )
104 | template(#item="{ item }")
105 | u-alert(
106 | :title="item.status"
107 | :description="item.description"
108 | :actions="item.actions"
109 | )
110 |
111 |
112 |
275 |
276 |
281 |
--------------------------------------------------------------------------------
/components/ui/form/VersionPicker.vue:
--------------------------------------------------------------------------------
1 |
2 | div
3 |
4 | u-button(
5 | @click="is_open = true"
6 | color="white"
7 | block
8 | )
9 | | Add
10 | u-kbd {{ metaSymbol }} F
11 |
12 | u-modal(
13 | v-model="is_open"
14 | :ui="{ width: 'w-full sm:max-w-4xl', height: 'h-dvh sm:h-[48rem]' }"
15 | )
16 | u-card.flex.flex-col.h-full(
17 | :ui="{ header: { padding: 'px-0 py-0 sm:px-0' }, body: { base: 'flex-grow overflow-y-auto' } }"
18 | )
19 | template(#header)
20 | u-input.py-1(
21 | v-model="query"
22 | placeholder="Search..."
23 | icon="i-heroicons-magnifying-glass"
24 | variant="none"
25 | size="xl"
26 | )
27 | u-divider
28 | .px-6.py-3
29 | u-button(
30 | @click="search_filter = 'replicate'"
31 | :color="search_filter === 'replicate' ? 'primary' : 'white'"
32 | label="On Replicate"
33 | size="md"
34 | )
35 | u-button.mx-3(
36 | @click="search_filter = 'huggingface'"
37 | :color="search_filter === 'huggingface' ? 'primary' : 'white'"
38 | label="On Hugging Face"
39 | size="md"
40 | )
41 | u-button(
42 | @click="search_filter = 'owner'"
43 | :color="search_filter === 'owner' ? 'primary' : 'white'"
44 | label="Your own"
45 | size="md"
46 | )
47 |
48 | .grid.grid-cols-1.gap-3(
49 | v-if="search_filter === 'huggingface'"
50 | )
51 | u-form-group(
52 | label="Huggingface path, or URL to the LoRA weights."
53 | help="Ex: alvdansen/frosting_lane_flux"
54 | name="hf_versions"
55 | )
56 | .flex.gap-3
57 | u-input.flex-grow(
58 | v-model="hf_version"
59 | @keydown.enter="addVersion"
60 | )
61 | u-button(
62 | @click="addVersion"
63 | icon="i-heroicons-plus"
64 | color="white"
65 | trailing
66 | ) Add
67 | u-button.mr-2.mt-2(
68 | v-for="(version, i) in hf_versions"
69 | :key="`hf_version-${i}`"
70 | color="white"
71 | block
72 | )
73 | .flex-grow.text-left.font-light.break-all {{ version }}
74 | u-divider.mx-1.h-4(orientation="vertical")
75 | u-icon.w-4(
76 | @click.stop="hf_versions = hf_versions.filter((v) => v !== version)"
77 | name="i-heroicons-trash"
78 | )
79 | .grid.grid-cols-4.gap-3(v-else)
80 | .cursor-pointer.break-all.p-3(
81 | v-for="(version, i) in results"
82 | :key="`version-${i}`"
83 | :class="versions.includes(version.version) ? 'bg-[#ebb305]' : 'hover:bg-[#f8f8f8]'"
84 | @click="onClick(version.version)"
85 | )
86 | img.aspect-1.object-cover.mb-2(:src="version?.cover_image_url || '/replicate-logo.svg'")
87 | .text-sm.font-bold {{ version.name }}
88 | .text-xs {{ version.owner }}
89 |
90 |
91 |
183 |
184 |
185 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtConfig({
2 | compatibilityDate: '2024-04-03',
3 | runtimeConfig: {
4 | public: {}
5 | },
6 | devtools: { enabled: false },
7 | ssr: false,
8 | nitro: {
9 | preset: 'vercel'
10 | },
11 | sourcemap: {
12 | server: false,
13 | client: false
14 | },
15 | app: {
16 | head: {
17 | htmlAttrs: {
18 | class: 'h-full'
19 | },
20 | title: 'ReFlux',
21 | link: [
22 | { rel: 'canonical', href: 'https://reflux.replicate.dev/' },
23 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
24 | ],
25 | meta: [
26 | { hid: 'charset', charset: 'utf-8' },
27 | { 'http-equiv': 'X-UA-Compatible', content: 'IE=edge' },
28 | {
29 | hid: 'viewport',
30 | name: 'viewport',
31 | content:
32 | 'width=device-width,height=device-height,initial-scale=1.0,user-scalable=0,minimum-scale=1.0,maximum-scale=1.0,viewport-fit=cover'
33 | },
34 | {
35 | hid: 'format-detection',
36 | name: 'format-detection',
37 | content: 'telephone=no'
38 | },
39 | {
40 | hid: 'description',
41 | name: 'description',
42 | content: 'Replicate Flux.1 and fine-tune editor.'
43 | },
44 | {
45 | hid: 'og:type',
46 | name: 'og:type',
47 | property: 'og:type',
48 | content: 'website'
49 | },
50 | {
51 | hid: 'og:url',
52 | name: 'og:url',
53 | property: 'og:url',
54 | content: 'https://reflux.replicate.dev'
55 | },
56 | {
57 | hid: 'og:site_name',
58 | name: 'og:site_name',
59 | property: 'og:site_name',
60 | content: 'reflux.replicate.dev'
61 | },
62 | {
63 | hid: 'og:title',
64 | name: 'og:title',
65 | property: 'og:title',
66 | content: 'ReFlux'
67 | },
68 | {
69 | hid: 'og:description',
70 | name: 'og:description',
71 | property: 'og:description',
72 | content: 'Replicate Flux.1 and fine-tune editor.'
73 | },
74 | {
75 | hid: 'og:image',
76 | name: 'og:image',
77 | property: 'og:image',
78 | content: 'https://reflux.replicate.dev/cover.jpg'
79 | },
80 | {
81 | hid: 'msapplication-TileColor',
82 | name: 'msapplication-TileColor',
83 | content: '#ffffff'
84 | },
85 | { hid: 'theme-color', name: 'theme-color', content: '#ffffff' },
86 | {
87 | hid: 'mobile-web-app-capable',
88 | name: 'mobile-web-app-capable',
89 | content: 'yes'
90 | },
91 | {
92 | hid: 'apple-mobile-web-app-title',
93 | name: 'apple-mobile-web-app-title',
94 | content: 'reflux.replicate.dev'
95 | }
96 | ],
97 | script: [
98 | /*
99 | {
100 | async: true,
101 | src: `https://www.googletagmanager.com/gtag/js?id=${process.env.GTAG_ID}`
102 | },
103 | {
104 | children: `
105 | window.dataLayer = window.dataLayer || [];
106 | function gtag(){dataLayer.push(arguments);}
107 | gtag('js', new Date());
108 | gtag('config', '${process.env.GTAG_ID}');`
109 | }
110 | */
111 | ],
112 | bodyAttrs: {
113 | class: 'antialiased overscroll-x-none'
114 | }
115 | }
116 | },
117 | modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt'],
118 | colorMode: {
119 | preference: 'light'
120 | },
121 | ui: {
122 | global: true
123 | }
124 | })
125 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reflux",
3 | "author": "Pontus Aurdal",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "nuxt build",
8 | "dev": "nuxt dev",
9 | "generate": "nuxt generate",
10 | "preview": "nuxt preview",
11 | "postinstall": "nuxt prepare"
12 | },
13 | "dependencies": {
14 | "@nuxt/ui": "^2.18.3",
15 | "@pinia/nuxt": "^0.5.2",
16 | "@vueuse/core": "^10.11.0",
17 | "@vueuse/nuxt": "^10.11.0",
18 | "all-the-public-replicate-models": "^1.310.0",
19 | "jszip": "^3.10.1",
20 | "konva": "^9.3.14",
21 | "lodash-es": "^4.17.21",
22 | "nuxt": "^3.12.4",
23 | "uuid": "^10.0.0",
24 | "vue": "latest"
25 | },
26 | "devDependencies": {
27 | "pug": "^3.0.3",
28 | "pug-plain-loader": "^1.1.0",
29 | "stylus": "^0.63.0",
30 | "stylus-loader": "^8.1.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 | .flex.flex-col.h-screen
3 | ui-top-panel
4 | .flex.flex-1.overflow-hidden
5 | ui-left-panel
6 | ui-canvas
7 | prediction-poller
8 |
9 |
10 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: 'none',
3 | tabWidth: 2,
4 | semi: false,
5 | singleQuote: true,
6 | bracketSpacing: true
7 | }
8 |
--------------------------------------------------------------------------------
/public/cover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replicate/reflux/01b0ea9811abfc38677d1d590f78f0a227be3210/public/cover.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replicate/reflux/01b0ea9811abfc38677d1d590f78f0a227be3210/public/favicon.ico
--------------------------------------------------------------------------------
/public/replicate-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/server/api/model.post.js:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | try {
3 | const {
4 | replicate_api_token,
5 | name,
6 | trigger_word,
7 | visibility = 'public'
8 | } = await readBody(event)
9 |
10 | // Get username
11 | let result = await fetch('https://api.replicate.com/v1/account', {
12 | method: 'GET',
13 | headers: {
14 | Authorization: `Bearer ${replicate_api_token}`,
15 | 'User-Agent': 'ReFlux/1.0'
16 | }
17 | })
18 | const { username } = await result.json()
19 |
20 | // Check if model already exists
21 | result = await fetch(
22 | `https://api.replicate.com/v1/models/${username}/${name}`,
23 | {
24 | method: 'GET',
25 | headers: {
26 | Authorization: `Bearer ${replicate_api_token}`,
27 | 'Content-Type': 'application/json',
28 | 'User-Agent': 'ReFlux/1.0'
29 | }
30 | }
31 | )
32 | let model = await result.json()
33 |
34 | // Create model
35 | if (!model?.name) {
36 | result = await fetch('https://api.replicate.com/v1/models', {
37 | method: 'POST',
38 | headers: {
39 | Authorization: `Bearer ${replicate_api_token}`,
40 | 'Content-Type': 'application/json',
41 | 'User-Agent': 'ReFlux/1.0'
42 | },
43 | body: JSON.stringify({
44 | owner: username,
45 | name,
46 | description: `A fine-tuned FLUX.1 model. Use trigger word "${
47 | trigger_word || 'TOK'
48 | }". Created with ReFlux (https://reflux.replicate.dev).`,
49 | hardware: 'gpu-t4',
50 | visibility
51 | })
52 | })
53 |
54 | model = await result.json()
55 | }
56 |
57 | return model
58 | } catch (e) {
59 | console.log('--- error (api/model): ', e)
60 |
61 | return {
62 | error: e.message
63 | }
64 | }
65 | })
66 |
--------------------------------------------------------------------------------
/server/api/prediction.get.js:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | try {
3 | const { token, ids } = getQuery(event)
4 | const id_array = ids.split(',')
5 |
6 | const results = await Promise.all(
7 | id_array.map((id) =>
8 | fetch(`https://api.replicate.com/v1/predictions/${id}`, {
9 | method: 'GET',
10 | headers: {
11 | Authorization: `Bearer ${token}`,
12 | 'User-Agent': 'ReFlux/1.0'
13 | }
14 | })
15 | )
16 | )
17 |
18 | const predictions = await Promise.all(
19 | results.map((result) => result.json())
20 | )
21 |
22 | // Remove potentially long data
23 | // delete json.logs
24 |
25 | return predictions
26 | } catch (e) {
27 | console.log('--- error (api/prediction): ', e)
28 |
29 | return {
30 | error: e.message
31 | }
32 | }
33 | })
34 |
--------------------------------------------------------------------------------
/server/api/prediction.post.js:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | try {
3 | const { replicate_api_token, version, input } = await readBody(event)
4 |
5 | const result = await fetch('https://api.replicate.com/v1/predictions', {
6 | method: 'POST',
7 | headers: {
8 | Authorization: `Bearer ${replicate_api_token}`,
9 | 'User-Agent': 'ReFlux/1.0'
10 | },
11 | body: JSON.stringify({
12 | version,
13 | input
14 | })
15 | })
16 |
17 | const prediction = await result.json()
18 |
19 | return prediction
20 | } catch (e) {
21 | console.log('--- error (api/prediction): ', e)
22 |
23 | return {
24 | error: e.message
25 | }
26 | }
27 | })
28 |
--------------------------------------------------------------------------------
/server/api/search.get.js:
--------------------------------------------------------------------------------
1 | import models from 'all-the-public-replicate-models'
2 |
3 | const getTriggerWord = (model) => {
4 | const description = model?.description || ''
5 | const default_example_prompt = model?.default_example?.input.prompt || ''
6 | const prompt_description =
7 | model?.latest_version?.openapi_schema?.components?.schemas?.Input
8 | ?.properties?.prompt?.description || ''
9 |
10 | const checkPatterns = (str) => {
11 | // Regular expressions for different trigger word patterns
12 | const quotedPattern = /"([^"]+)"|'([^']+)'/
13 | const allCapsPattern = /\b[A-Z]{2,}\b/
14 | const stylePattern = /(\S+(?:\s+\S+)*)\s+style/i
15 |
16 | // Check for quoted words
17 | const quotedMatch = str.match(quotedPattern)
18 | if (quotedMatch) return quotedMatch[1] || quotedMatch[2]
19 |
20 | // Check for all-caps words
21 | const allCapsMatch = str.match(allCapsPattern)
22 | if (allCapsMatch) return allCapsMatch[0]
23 |
24 | // Check for words followed by 'style'
25 | // const styleMatch = str.match(stylePattern)
26 | // if (styleMatch) return styleMatch[1]
27 |
28 | return null
29 | }
30 |
31 | let word = null
32 | word = checkPatterns(description)
33 | if (word) return word
34 | word = checkPatterns(prompt_description)
35 | if (word) return word
36 | word = checkPatterns(default_example_prompt)
37 | if (word) return word
38 |
39 | // Default return if no trigger word is found
40 | return 'TOK'
41 | }
42 |
43 | export default defineEventHandler(async (event) => {
44 | try {
45 | const { token } = getQuery(event)
46 |
47 | // Get trainings
48 | // TODO: pagination
49 | const result = await fetch(`https://api.replicate.com/v1/trainings`, {
50 | method: 'GET',
51 | headers: {
52 | Authorization: `Bearer ${token}`,
53 | 'User-Agent': 'ReFlux/1.0'
54 | }
55 | })
56 | const { results } = await result.json()
57 |
58 | const owner = (
59 | await Promise.all(
60 | (results || []).map(async (training) => {
61 | try {
62 | // Not a flux training
63 | if (
64 | training?.model !== 'ostris/flux-dev-lora-trainer' ||
65 | training?.status === 'failed'
66 | ) {
67 | return null
68 | }
69 |
70 | const [full_name, version] = training?.output?.version.split(':')
71 | const [username, name] = full_name.split('/')
72 | const model_result = await fetch(
73 | `https://api.replicate.com/v1/models/${username}/${name}`,
74 | {
75 | method: 'GET',
76 | headers: {
77 | Authorization: `Bearer ${token}`,
78 | 'Content-Type': 'application/json',
79 | 'User-Agent': 'ReFlux/1.0'
80 | }
81 | }
82 | )
83 | const model = await model_result.json()
84 |
85 | return (
86 | (model?.name || '') +
87 | ' ' +
88 | (model?.description || '')
89 | ).includes('flux')
90 | ? {
91 | is_owner: true,
92 | owner: model.owner,
93 | name: model.name,
94 | description: model?.description || '',
95 | version: model?.latest_version?.id,
96 | cover_image_url: model?.cover_image_url || null,
97 | trigger: getTriggerWord(model)
98 | }
99 | : null
100 | } catch (e) {
101 | console.log(e)
102 | return null
103 | }
104 | })
105 | )
106 | ).filter((v) => !!v)
107 |
108 | // Use all-the-public-replicate-models package instead
109 | const _public = models
110 | .filter(
111 | (i) =>
112 | ((i?.name || '') + ' ' + (i?.description || '')).includes('flux') &&
113 | i?.latest_version?.openapi_schema?.components?.schemas?.TrainingInput
114 | )
115 | .map((i) => ({
116 | is_owner: false,
117 | owner: i.owner,
118 | name: i.name,
119 | description: i?.description || '',
120 | version: i?.latest_version?.id,
121 | cover_image_url: i?.cover_image_url || null,
122 | trigger: getTriggerWord(i)
123 | }))
124 |
125 | return [...owner, ..._public]
126 | } catch (e) {
127 | console.log('--- error (api/search): ', e)
128 |
129 | return {
130 | error: e.message
131 | }
132 | }
133 | })
134 |
--------------------------------------------------------------------------------
/server/api/training.post.js:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip'
2 |
3 | const downloadAndZipImages = async (urls) => {
4 | const zip = new JSZip()
5 | const promises = urls.map(async (url) => {
6 | const response = await fetch(url)
7 | const arrayBuffer = await response.arrayBuffer()
8 | const fileName = url.split('/').pop()
9 | zip.file(fileName, arrayBuffer)
10 | })
11 | await Promise.all(promises)
12 | const zipBlob = await zip.generateAsync({ type: 'blob' })
13 | const arrayBuffer = await zipBlob.arrayBuffer()
14 | const base64data = Buffer.from(arrayBuffer).toString('base64')
15 | return `data:application/zip;base64,${base64data}`
16 | }
17 |
18 | export default defineEventHandler(async (event) => {
19 | try {
20 | const { replicate_api_token, destination, input } = await readBody(event)
21 |
22 | // Download and ZIP files
23 | const input_images_base64 = await downloadAndZipImages(input?.input_images)
24 |
25 | const result = await fetch(
26 | 'https://api.replicate.com/v1/models/ostris/flux-dev-lora-trainer/versions/7f53f82066bcdfb1c549245a624019c26ca6e3c8034235cd4826425b61e77bec/trainings',
27 | {
28 | method: 'POST',
29 | headers: {
30 | Authorization: `Bearer ${replicate_api_token}`,
31 | 'User-Agent': 'ReFlux/1.0'
32 | },
33 | body: JSON.stringify({
34 | destination,
35 | input: {
36 | ...input,
37 | input_images: input_images_base64
38 | }
39 | })
40 | }
41 | )
42 |
43 | const prediction = await result.json()
44 |
45 | return prediction
46 | } catch (e) {
47 | console.log('--- error (api/prediction): ', e)
48 |
49 | return {
50 | error: e.message
51 | }
52 | }
53 | })
54 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../.nuxt/tsconfig.server.json"
3 | }
4 |
--------------------------------------------------------------------------------
/stores/prediction.js:
--------------------------------------------------------------------------------
1 | import { useLocalStorage } from '@vueuse/core'
2 |
3 | import { useVersionStore } from './version'
4 |
5 | const parseAspectRatio = (aspectRatio) => {
6 | const [width, height] = aspectRatio.split(':').map(Number)
7 | return { width, height }
8 | }
9 |
10 | const urlToBase64 = async (urlOrArray) => {
11 | const convertSingle = async (url) => {
12 | if (typeof url !== 'string' || !url.startsWith('https')) {
13 | return url
14 | }
15 |
16 | try {
17 | // Try to extract file extension from URL
18 | const urlParts = url.split('/')
19 | const fileName = urlParts[urlParts.length - 1]
20 | const fileExtension = fileName.split('.').pop().toLowerCase()
21 |
22 | const response = await fetch(url)
23 | const blob = await response.blob()
24 |
25 | // Determine file type
26 | let fileType
27 | if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension)) {
28 | fileType = `image/${fileExtension === 'jpg' ? 'jpeg' : fileExtension}`
29 | } else {
30 | fileType = blob.type || 'application/octet-stream'
31 | }
32 |
33 | return new Promise((resolve, reject) => {
34 | const reader = new FileReader()
35 | reader.onloadend = () => {
36 | const base64data = reader.result
37 | const dataUri = `data:${fileType};base64,${base64data.split(',')[1]}`
38 | resolve(dataUri)
39 | }
40 | reader.onerror = reject
41 | reader.readAsDataURL(blob)
42 | })
43 | } catch (error) {
44 | console.error('Error converting URL to base64:', error)
45 | return url
46 | }
47 | }
48 |
49 | if (Array.isArray(urlOrArray)) {
50 | return Promise.all(urlOrArray.map(convertSingle))
51 | } else {
52 | return convertSingle(urlOrArray)
53 | }
54 | }
55 |
56 | export const usePredictionStore = defineStore('predictionStore', {
57 | state: () => ({
58 | replicate_api_token: useLocalStorage('reflux-replicate-api-token', null),
59 | outputs: useLocalStorage('reflux-outputs', []),
60 | trainings: useLocalStorage('reflux-trainings', [])
61 | }),
62 | actions: {
63 | async createBatch({ versions, hf_versions, num_outputs, merge, input }) {
64 | try {
65 | const versionStore = useVersionStore()
66 | const combined_versions = [...versions, ...hf_versions]
67 |
68 | let promises = []
69 |
70 | // Merging, use num_outputs
71 | if (combined_versions.length === 2 && merge) {
72 | // Use the Replicate model + side car
73 | if (versions.length > 0) {
74 | const extra_lora =
75 | hf_versions.length > 0
76 | ? `huggingface.co/${hf_versions[0]}`
77 | : versionStore.getOwnerNameByVersion(versions[1])
78 |
79 | promises = Array.from(Array(num_outputs).keys()).map(() =>
80 | $fetch('/api/prediction', {
81 | method: 'POST',
82 | body: {
83 | replicate_api_token: this.replicate_api_token,
84 | version: versions[0],
85 | input: {
86 | ...input,
87 | extra_lora,
88 | extra_lora_scale: input.lora_scale, // For now
89 | seed: Math.floor(Math.random() * 1000)
90 | }
91 | }
92 | })
93 | )
94 | // Use lucataco/flux-dev-multi-lora
95 | } else {
96 | promises = Array.from(Array(num_outputs).keys()).map(() =>
97 | $fetch('/api/prediction', {
98 | method: 'POST',
99 | body: {
100 | replicate_api_token: this.replicate_api_token,
101 | // https://replicate.com/lucataco/flux-dev-multi-lora
102 | version:
103 | 'a738942df15c8c788b076ddd052256ba7923aade687b12109ccc64b2c3483aa1',
104 | input: {
105 | ...input,
106 | hf_loras: hf_versions,
107 | lora_scales: Array.from(
108 | Array(hf_versions.length).keys()
109 | ).map(() => input.lora_scale),
110 | seed: Math.floor(Math.random() * 1000)
111 | }
112 | }
113 | })
114 | )
115 | }
116 | // Not merging, use num_outputs
117 | } else if (combined_versions.length < 2) {
118 | if (versions.length > 0) {
119 | promises = Array.from(Array(num_outputs).keys()).map(() =>
120 | $fetch('/api/prediction', {
121 | method: 'POST',
122 | body: {
123 | replicate_api_token: this.replicate_api_token,
124 | version: versions[0],
125 | input: {
126 | ...input,
127 | seed: Math.floor(Math.random() * 1000)
128 | }
129 | }
130 | })
131 | )
132 | } else if (hf_versions.length > 0) {
133 | promises = Array.from(Array(num_outputs).keys()).map(() =>
134 | $fetch('/api/prediction', {
135 | method: 'POST',
136 | body: {
137 | replicate_api_token: this.replicate_api_token,
138 | // https://replicate.com/lucataco/flux-dev-multi-lora
139 | version:
140 | 'a738942df15c8c788b076ddd052256ba7923aade687b12109ccc64b2c3483aa1',
141 | input: {
142 | ...input,
143 | hf_loras: [hf_versions[0]],
144 | lora_scales: [input.lora_scale],
145 | seed: Math.floor(Math.random() * 1000)
146 | }
147 | }
148 | })
149 | )
150 | }
151 |
152 | // Not merging, create one of each
153 | } else {
154 | promises.push(
155 | ...versions.map((version) =>
156 | $fetch('/api/prediction', {
157 | method: 'POST',
158 | body: {
159 | replicate_api_token: this.replicate_api_token,
160 | version,
161 | input
162 | }
163 | })
164 | ),
165 | ...hf_versions.map((hf_version) =>
166 | $fetch('/api/prediction', {
167 | method: 'POST',
168 | body: {
169 | replicate_api_token: this.replicate_api_token,
170 | // https://replicate.com/lucataco/flux-dev-multi-lora
171 | version:
172 | 'a738942df15c8c788b076ddd052256ba7923aade687b12109ccc64b2c3483aa1',
173 | input: {
174 | ...input,
175 | hf_loras: [hf_version],
176 | lora_scales: [input.lora_scale]
177 | }
178 | }
179 | })
180 | )
181 | )
182 | }
183 |
184 | const predictions = await Promise.all(promises)
185 |
186 | const baseSize = 300
187 | const aspectRatio = parseAspectRatio(input.aspect_ratio)
188 | const width = baseSize
189 | const height = (aspectRatio.width / aspectRatio.height) * baseSize
190 |
191 | predictions.forEach((prediction, index) => {
192 | this.outputs.push({
193 | id: prediction.id,
194 | status: prediction.status,
195 | input,
196 | output: null,
197 | metadata: {
198 | prediction_id: prediction.id,
199 | name: versionStore.getOwnerNameByVersion(prediction.version),
200 | x: 0,
201 | y: 0,
202 | rotation: 0,
203 | width,
204 | height
205 | }
206 | })
207 | })
208 |
209 | return predictions
210 | } catch (e) {
211 | console.log('--- (stores/prediction) error:', e.message)
212 | }
213 | },
214 | async createTrainingPrestep({ name, trigger_word, visibility, input }) {
215 | try {
216 | const model = await $fetch('/api/model', {
217 | method: 'POST',
218 | body: {
219 | replicate_api_token: this.replicate_api_token,
220 | name,
221 | trigger_word,
222 | visibility
223 | }
224 | })
225 |
226 | if (!model?.owner) {
227 | throw new Error('failed to create model')
228 | }
229 |
230 | const prediction = await $fetch('/api/prediction', {
231 | method: 'POST',
232 | body: {
233 | replicate_api_token: this.replicate_api_token,
234 | // https://replicate.com/fofr/consistent-character
235 | version:
236 | '9c77a3c2f884193fcee4d89645f02a0b9def9434f9e03cb98460456b831c8772',
237 | input: {
238 | ...input,
239 | number_of_outputs: 15,
240 | number_of_images_per_pose: 1,
241 | randomise_poses: true,
242 | output_format: 'webp',
243 | output_quality: 80
244 | }
245 | }
246 | })
247 |
248 | this.trainings.push({
249 | id: prediction.id,
250 | status: prediction.status,
251 | input,
252 | output: null,
253 | metadata: {
254 | prediction_id: prediction.id,
255 | pipeline_stage: 'preprocessing',
256 | trigger_word,
257 | destination: model
258 | }
259 | })
260 |
261 | return prediction
262 | } catch (e) {
263 | console.log('--- (stores/prediction) error:', e.message)
264 | }
265 | },
266 | async createTraining(training) {
267 | try {
268 | const { trigger_word, destination } = training?.metadata
269 | const output = training?.output || []
270 |
271 | // Fail early
272 | if (output.length <= 0) {
273 | const index = this.trainings.findIndex((i) => i.id === training.id)
274 | if (index !== -1) {
275 | this.trainings[index].status = 'failed'
276 | }
277 | return null
278 | }
279 |
280 | const input = {
281 | input_images: output,
282 | trigger_word
283 | }
284 |
285 | const prediction = await $fetch('/api/training', {
286 | method: 'POST',
287 | body: {
288 | replicate_api_token: this.replicate_api_token,
289 | destination: `${destination?.owner}/${destination?.name}`,
290 | input
291 | }
292 | })
293 |
294 | // Update training
295 | const index = this.trainings.findIndex((i) => i.id === training.id)
296 | if (index !== -1) {
297 | this.trainings[index].id = prediction.id
298 | this.trainings[index].status = prediction.status
299 | this.trainings[index].input = input
300 | this.trainings[index].output = null
301 | this.trainings[index].metadata.prediction_id = prediction.id
302 | this.trainings[index].metadata.pipeline_stage = 'processing'
303 | }
304 |
305 | return prediction
306 | } catch (e) {
307 | console.log('--- (stores/prediction) error:', e.message)
308 | }
309 | },
310 | async pollIncompletePredictions() {
311 | try {
312 | const prediction_ids = [
313 | ...new Set(
314 | this.incompletePredictions
315 | .map((output) => output?.metadata?.prediction_id || null)
316 | .filter((id) => id)
317 | )
318 | ]
319 | const predictions = await $fetch(
320 | `/api/prediction?ids=${prediction_ids.join(',')}&token=${
321 | this.replicate_api_token
322 | }`
323 | )
324 |
325 | for (const prediction of predictions) {
326 | // Update outputs with the same prediction_id in the state
327 | let targets = this.outputs.filter(
328 | (i) => i?.metadata?.prediction_id === prediction.id
329 | )
330 |
331 | for (const target of targets) {
332 | // Update the corresponding output in the state
333 | const index = this.outputs.findIndex((i) => i.id === target.id)
334 | if (index !== -1) {
335 | this.outputs[index] = {
336 | ...this.outputs[index],
337 | input: prediction.input,
338 | status: prediction.status,
339 | output: await urlToBase64(prediction.output)
340 | }
341 | }
342 | }
343 |
344 | // Update trainings with the same prediction_id in the state
345 | targets = this.trainings.filter(
346 | (i) => i?.metadata?.prediction_id === prediction.id
347 | )
348 |
349 | for (const target of targets) {
350 | // Update the corresponding output in the state
351 | const index = this.trainings.findIndex((i) => i.id === target.id)
352 | if (index !== -1) {
353 | this.trainings[index] = {
354 | ...this.trainings[index],
355 | input: prediction.input,
356 | status: prediction.status,
357 | output: prediction.output
358 | }
359 | }
360 | }
361 | }
362 | } catch (e) {
363 | console.log(
364 | '--- (stores/prediction) error polling incomplete predictions:',
365 | e.message
366 | )
367 | }
368 | },
369 | updateOutputPosition({ id, x, y, rotation, width, height }) {
370 | const index = this.outputs.findIndex((output) => output.id === id)
371 | if (index !== -1) {
372 | this.outputs[index].metadata.x = x
373 | this.outputs[index].metadata.y = y
374 | this.outputs[index].metadata.rotation = rotation
375 | if (width !== undefined) this.outputs[index].metadata.width = width
376 | if (height !== undefined) this.outputs[index].metadata.height = height
377 | }
378 | },
379 | addOutput(output) {
380 | if (Array.isArray(output)) {
381 | this.outputs.push(...output)
382 | } else {
383 | this.outputs.push(output)
384 | }
385 | },
386 | removeOutput(id) {
387 | if (Array.isArray(id)) {
388 | this.outputs = this.outputs.filter((output) => !id.includes(output.id))
389 | } else {
390 | this.outputs = this.outputs.filter((output) => output.id !== id)
391 | }
392 | }
393 | },
394 | getters: {
395 | incompletePredictions: (state) =>
396 | [...state.outputs, ...state.trainings].filter(
397 | (output) => output.status !== 'succeeded' && output.status !== 'failed'
398 | )
399 | }
400 | })
401 |
--------------------------------------------------------------------------------
/stores/version.js:
--------------------------------------------------------------------------------
1 | export const useVersionStore = defineStore('versionStore', {
2 | state: () => ({
3 | version_options: []
4 | }),
5 | actions: {
6 | setVersionOptions(val) {
7 | this.version_options = val
8 | }
9 | },
10 | getters: {
11 | getOwnerNameByVersion: (state) => (version) => {
12 | const item = state.version_options.find((i) => i.version === version)
13 | return item ? `${item.owner}/${item.name}` : null
14 | },
15 | getVersionByName: (state) => (name) => {
16 | const item = state.version_options.find((i) => i.name === name)
17 | return item ? item.version : null
18 | },
19 | getTriggerByVersion: (state) => (version) => {
20 | const item = state.version_options.find((i) => i.version === version)
21 | return item ? item.trigger : null
22 | }
23 | }
24 | })
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://nuxt.com/docs/guide/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------