init project

This commit is contained in:
王性驊 2025-12-16 18:08:51 +08:00
commit beccc8e652
41 changed files with 15969 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

16
app/app.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>
<style>
@import './assets/css/main.css';
/* iOS 風格增強 */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

4
app/assets/css/main.css Normal file
View File

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,24 @@
<template>
<button
@click="handleBack"
class="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>返回</span>
</button>
</template>
<script setup lang="ts">
const router = useRouter()
function handleBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<button
:type="type"
:disabled="disabled"
:class="buttonClass"
@click="$emit('click', $event)"
>
<slot />
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'outline'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'button',
variant: 'primary',
disabled: false
})
defineEmits<{
click: [event: MouseEvent]
}>()
const buttonClass = computed(() => {
const base = 'px-6 py-3 rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 active:scale-95'
const variants = {
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-400 shadow-lg shadow-blue-500/30 disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed',
outline: 'border-2 border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-400 disabled:border-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
}
return `${base} ${variants[props.variant]}`
})
</script>

View File

@ -0,0 +1,33 @@
<template>
<div :class="cardClass">
<div v-if="$slots.header" class="px-6 py-4 border-b border-gray-200 text-gray-900">
<slot name="header" />
</div>
<div class="px-6 py-4 text-gray-900">
<slot />
</div>
<div v-if="$slots.footer" class="px-6 py-4 border-t border-gray-200 bg-gray-50/50 text-gray-900">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
variant?: 'default' | 'elevated'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
const cardClass = computed(() => {
const base = 'bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden text-gray-900'
const variants = {
default: `${base} shadow-sm`,
elevated: `${base} shadow-2xl shadow-black/5`
}
return variants[props.variant]
})
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="flex flex-col">
<label v-if="label" :for="inputId" class="mb-1 text-sm font-medium text-gray-700">
{{ label }}
</label>
<input
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
class="px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 disabled:bg-gray-50 disabled:cursor-not-allowed transition-all bg-white/90 backdrop-blur-sm text-gray-900 placeholder:text-gray-400"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<span v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string
type?: string
label?: string
placeholder?: string
disabled?: boolean
required?: boolean
error?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
required: false
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const inputId = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="flex flex-col">
<label v-if="label" :for="textareaId" class="mb-1 text-sm font-medium text-gray-700">
{{ label }}
</label>
<textarea
:id="textareaId"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:rows="rows"
class="px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 disabled:bg-gray-50 disabled:cursor-not-allowed resize-y transition-all bg-white/90 backdrop-blur-sm text-gray-900 placeholder:text-gray-400"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
<span v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</span>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string
label?: string
placeholder?: string
disabled?: boolean
required?: boolean
rows?: number
error?: string
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
required: false,
rows: 4
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const textareaId = computed(() => `textarea-${Math.random().toString(36).substr(2, 9)}`)
</script>

View File

@ -0,0 +1,242 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="handleClose">
<BaseCard variant="elevated" class="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">編輯人物{{ character?.name }}</h2>
<button @click="handleClose" class="text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<div v-if="character" class="space-y-6">
<!-- 基本資訊 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">基本資訊</h3>
<div class="space-y-4">
<BaseInput
v-model="editedCharacter.name"
label="角色名稱"
:required="true"
/>
<BaseTextarea
v-model="editedCharacter.description"
label="角色描述"
:rows="3"
/>
<BaseInput
v-model="editedCharacter.role"
label="角色定位"
/>
</div>
</div>
<!-- 外觀設定 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">外觀設定</h3>
<div class="space-y-4">
<BaseInput
v-model="editedCharacter.clothing"
label="服裝"
placeholder="例如T恤、牛仔褲"
/>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">配件</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="(accessory, index) in editedCharacter.accessories"
:key="index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{{ accessory }}
<button
@click="removeAccessory(index)"
class="text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
</div>
<div class="flex gap-2">
<BaseInput
v-model="newAccessory"
placeholder="輸入配件名稱"
class="flex-1"
@keyup.enter="addAccessory"
/>
<BaseButton variant="outline" @click="addAccessory">新增</BaseButton>
</div>
</div>
</div>
</div>
<!-- 顏色設定 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">顏色設定</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">髮色</label>
<input
v-model="editedCharacter.colors.hair"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">膚色</label>
<input
v-model="editedCharacter.colors.skin"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">服裝顏色</label>
<input
v-model="editedCharacter.colors.clothing"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
</div>
</div>
<!-- 三視圖預覽 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">三視圖</h3>
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!editedCharacter.frontView" class="text-gray-400 text-sm">正面</span>
<img v-else :src="editedCharacter.frontView" alt="正面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" @click="generateView('front')" class="text-sm px-3 py-1 w-full">
生成
</BaseButton>
</div>
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!editedCharacter.sideView" class="text-gray-400 text-sm">側面</span>
<img v-else :src="editedCharacter.sideView" alt="側面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" @click="generateView('side')" class="text-sm px-3 py-1 w-full">
生成
</BaseButton>
</div>
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!editedCharacter.backView" class="text-gray-400 text-sm">背面</span>
<img v-else :src="editedCharacter.backView" alt="背面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" @click="generateView('back')" class="text-sm px-3 py-1 w-full">
生成
</BaseButton>
</div>
</div>
</div>
<!-- 操作按鈕 -->
<div class="flex justify-end gap-4 pt-4 border-t border-gray-200">
<BaseButton variant="outline" @click="handleClose">取消</BaseButton>
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
</div>
</div>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import type { CharacterAsset } from '~/types/storyboard'
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
interface Props {
isOpen: boolean
character: CharacterAsset | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
save: [character: CharacterAsset]
}>()
const { generateCharacterView: generateCharacterViewImage, isLoading: isGenerating } = useAIImageGeneration()
const editedCharacter = ref<CharacterAsset | null>(null)
const newAccessory = ref('')
const generatingView = ref<'front' | 'side' | 'back' | null>(null)
watch(() => props.character, (newChar) => {
if (newChar) {
editedCharacter.value = {
...newChar,
accessories: [...(newChar.accessories || [])],
colors: {
hair: newChar.colors?.hair || '#000000',
skin: newChar.colors?.skin || '#FFDBAC',
clothing: newChar.colors?.clothing || '#000000'
}
}
}
}, { immediate: true })
function handleClose() {
emit('close')
}
function handleSave() {
if (editedCharacter.value) {
emit('save', editedCharacter.value)
handleClose()
}
}
function addAccessory() {
if (newAccessory.value.trim() && editedCharacter.value) {
if (!editedCharacter.value.accessories) {
editedCharacter.value.accessories = []
}
editedCharacter.value.accessories.push(newAccessory.value.trim())
newAccessory.value = ''
}
}
function removeAccessory(index: number) {
if (editedCharacter.value?.accessories) {
editedCharacter.value.accessories.splice(index, 1)
}
}
async function generateView(view: 'front' | 'side' | 'back') {
if (!editedCharacter.value) return
generatingView.value = view
try {
const style = '寫實風格' // workflow
const imageUrl = await generateCharacterViewImage(
editedCharacter.value.name,
editedCharacter.value.description,
view,
style
)
if (view === 'front') {
editedCharacter.value.frontView = imageUrl
} else if (view === 'side') {
editedCharacter.value.sideView = imageUrl
} else if (view === 'back') {
editedCharacter.value.backView = imageUrl
}
} catch (err) {
console.error('生成視圖失敗:', err)
} finally {
generatingView.value = null
}
}
</script>

View File

@ -0,0 +1,214 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="handleClose">
<BaseCard variant="elevated" class="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">編輯場景{{ scene?.name }}</h2>
<button @click="handleClose" class="text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<div v-if="scene" class="space-y-6">
<!-- 基本資訊 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">基本資訊</h3>
<div class="space-y-4">
<BaseInput
v-model="editedScene.name"
label="場景名稱"
:required="true"
/>
<BaseTextarea
v-model="editedScene.description"
label="場景描述"
:rows="3"
/>
<BaseInput
v-model="editedScene.environment"
label="環境設定"
placeholder="例如:室內、室外、城市、森林"
/>
</div>
</div>
<!-- 場景設定 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">場景設定</h3>
<div class="space-y-4">
<BaseInput
v-model="editedScene.lighting"
label="光線設定"
placeholder="例如:自然光、人工光、昏暗"
/>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">道具</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="(prop, index) in editedScene.props"
:key="index"
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm"
>
{{ prop }}
<button
@click="removeProp(index)"
class="text-green-600 hover:text-green-800"
>
×
</button>
</span>
</div>
<div class="flex gap-2">
<BaseInput
v-model="newProp"
placeholder="輸入道具名稱"
class="flex-1"
@keyup.enter="addProp"
/>
<BaseButton variant="outline" @click="addProp">新增</BaseButton>
</div>
</div>
</div>
</div>
<!-- 顏色設定 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">顏色設定</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">主色</label>
<input
v-model="editedScene.colors.primary"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">次色</label>
<input
v-model="editedScene.colors.secondary"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">強調色</label>
<input
v-model="editedScene.colors.accent"
type="color"
class="w-full h-10 rounded-lg border border-gray-200"
/>
</div>
</div>
</div>
<!-- 場景圖片預覽 -->
<div>
<h3 class="font-semibold text-gray-900 mb-4">場景圖片</h3>
<div class="aspect-video bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!editedScene.image" class="text-gray-400">場景圖片</span>
<img v-else :src="editedScene.image" alt="場景" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton
variant="outline"
@click="generateImage"
:disabled="isGenerating"
class="w-full"
>
{{ isGenerating ? '生成中...' : '生成場景圖片' }}
</BaseButton>
</div>
<!-- 操作按鈕 -->
<div class="flex justify-end gap-4 pt-4 border-t border-gray-200">
<BaseButton variant="outline" @click="handleClose">取消</BaseButton>
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
</div>
</div>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import type { SceneAsset } from '~/types/storyboard'
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
interface Props {
isOpen: boolean
scene: SceneAsset | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
save: [scene: SceneAsset]
}>()
const { generateSceneImage: generateSceneImageUrl, isLoading: isGenerating } = useAIImageGeneration()
const editedScene = ref<SceneAsset | null>(null)
const newProp = ref('')
watch(() => props.scene, (newScene) => {
if (newScene) {
editedScene.value = {
...newScene,
props: [...(newScene.props || [])],
colors: {
primary: newScene.colors?.primary || '#FFFFFF',
secondary: newScene.colors?.secondary || '#CCCCCC',
accent: newScene.colors?.accent || '#000000'
}
}
}
}, { immediate: true })
function handleClose() {
emit('close')
}
function handleSave() {
if (editedScene.value) {
emit('save', editedScene.value)
handleClose()
}
}
function addProp() {
if (newProp.value.trim() && editedScene.value) {
if (!editedScene.value.props) {
editedScene.value.props = []
}
editedScene.value.props.push(newProp.value.trim())
newProp.value = ''
}
}
function removeProp(index: number) {
if (editedScene.value?.props) {
editedScene.value.props.splice(index, 1)
}
}
async function generateImage() {
if (!editedScene.value) return
try {
const style = '寫實風格' // workflow
const imageUrl = await generateSceneImageUrl(
editedScene.value.name,
editedScene.value.description,
style
)
editedScene.value.image = imageUrl
} catch (err) {
console.error('生成場景圖片失敗:', err)
}
}
</script>

View File

@ -0,0 +1,106 @@
/**
* AI Composable
*
*/
import { useAIProvider } from './useAIProvider'
import { getImageGenerationModel } from '~/utils/storage'
import { DEFAULT_IMAGE_GENERATION_MODEL } from '~/utils/clients/gemini-models'
import { getModelProvider } from '~/utils/clients/all-models'
export function useAIImageGeneration() {
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
*
* Gemini Grok API
* API 調 provider 調
*/
async function generateImage(prompt: string): Promise<string> {
isLoading.value = true
error.value = null
try {
const modelName = getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL
const provider = useAIProvider(modelName)
const providerType = getModelProvider(modelName)
// 根據不同的 provider 使用不同的圖像生成方式
// 目前 Gemini 和 Grok 可能沒有直接的圖像生成 API
// 這裡使用文字生成模型來生成圖像描述的 prompt然後由前端處理
// 實際應用中,可能需要整合專門的圖像生成服務(如 DALL-E, Midjourney 等)
// 暫時返回一個提示,說明需要整合圖像生成服務
// 實際實作時,這裡應該調用圖像生成 API
throw new Error('圖像生成功能需要整合專門的圖像生成服務。目前可以使用文字模型生成圖像描述,然後手動生成圖像。')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
error.value = errorMessage
throw err
} finally {
isLoading.value = false
}
}
/**
*
*/
async function generateCharacterView(
characterName: string,
characterDescription: string,
view: 'front' | 'side' | 'back',
style?: string
): Promise<string> {
const viewNames = {
front: '正面',
side: '側面',
back: '背面'
}
const prompt = `請生成一個${viewNames[view]}視圖的人物圖片,要求:
- ${characterName}
- ${characterDescription}
- ${viewNames[view]}
-
- ${style || '寫實風格'}
- PNG
- 512x512
`
// 目前返回提示文字,實際應該返回圖像 URL 或 base64
// 這裡可以整合實際的圖像生成 API
return await generateImage(prompt)
}
/**
*
*/
async function generateSceneImage(
sceneName: string,
sceneDescription: string,
style?: string
): Promise<string> {
const prompt = `請生成一個場景圖片,要求:
- ${sceneName}
- ${sceneDescription}
-
- ${style || '寫實風格'}
- PNG
- 1024x1024
`
return await generateImage(prompt)
}
return {
generateImage,
generateCharacterView,
generateSceneImage,
isLoading: readonly(isLoading),
error: readonly(error)
}
}

View File

@ -0,0 +1,24 @@
/**
* AI Provider Selector Composable
* provider
*/
import type { AIProvider } from '~/types/ai'
import { GeminiClient } from '~/utils/clients/gemini'
import { GrokClient } from '~/utils/clients/grok'
import { getProviderForModel } from '~/utils/clients/all-models'
/**
* AI Provider
* @param modelName - 使Gemini
*/
export function useAIProvider(modelName?: string): AIProvider {
if (modelName) {
const provider = getProviderForModel(modelName)
return provider === 'grok' ? new GrokClient() : new GeminiClient()
}
// 預設使用 Gemini
return new GeminiClient()
}

View File

@ -0,0 +1,42 @@
/**
* AI Composable
* UI composable
*/
import { useAIProvider } from './useAIProvider'
import { buildStoryboardPrompt, type StoryboardInput } from '~/utils/ai/prompts'
import { getTextModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL as DEFAULT_MODEL } from '~/utils/clients/gemini-models'
export function useAIStoryboard() {
const isLoading = ref(false)
const error = ref<string | null>(null)
async function generate(input: StoryboardInput): Promise<string> {
isLoading.value = true
error.value = null
try {
// 根據選擇的模型自動選擇對應的 provider
const modelName = getTextModel() || DEFAULT_MODEL
const provider = useAIProvider(modelName)
const prompt = buildStoryboardPrompt(input)
const result = await provider.generateStoryboard(prompt)
return result
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
error.value = errorMessage
throw err
} finally {
isLoading.value = false
}
}
return {
generate,
isLoading: readonly(isLoading),
error: readonly(error)
}
}

View File

@ -0,0 +1,74 @@
/**
* AI Composable
*
*/
import { useAIProvider } from './useAIProvider'
import { buildStoryAnalysisPrompt, type StoryAnalysisInput } from '~/utils/ai/prompts'
import { getTextModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL } from '~/utils/clients/gemini-models'
import { getVisionModel } from '~/utils/storage'
import { DEFAULT_VISION_MODEL } from '~/utils/clients/gemini-models'
import type { StoryAnalysis } from '~/types/storyboard'
export function useAIStoryboardAnalysis() {
const isLoading = ref(false)
const error = ref<string | null>(null)
async function analyze(input: StoryAnalysisInput): Promise<StoryAnalysis> {
isLoading.value = true
error.value = null
try {
// 如果有圖片,使用視覺模型;否則使用文字模型
const modelName = input.styleImage
? (getVisionModel() || DEFAULT_VISION_MODEL)
: (getTextModel() || DEFAULT_TEXT_MODEL)
const provider = useAIProvider(modelName)
let prompt = buildStoryAnalysisPrompt(input)
// 如果有風格圖片,需要傳遞圖片給 API
if (input.styleImage) {
// 這裡需要根據 provider 類型處理圖片
// 暫時先傳遞文字 prompt實際實作時需要處理圖片
const result = await provider.analyzeCamera({
prompt,
image: {
mimeType: 'image/jpeg',
data: input.styleImage.replace(/^data:image\/\w+;base64,/, '')
}
})
// 解析 JSON 結果
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (jsonMatch) {
return JSON.parse(jsonMatch[0]) as StoryAnalysis
}
throw new Error('無法解析分析結果')
} else {
const result = await provider.generateStoryboard(prompt)
// 解析 JSON 結果
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (jsonMatch) {
return JSON.parse(jsonMatch[0]) as StoryAnalysis
}
throw new Error('無法解析分析結果')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知錯誤'
error.value = errorMessage
throw err
} finally {
isLoading.value = false
}
}
return {
analyze,
isLoading: readonly(isLoading),
error: readonly(error)
}
}

View File

@ -0,0 +1,580 @@
<template>
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="flex items-center gap-4 mb-8">
<BaseBackButton />
<div class="flex-1 flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900">AI 分鏡表</h1>
<div class="flex items-center gap-4">
<div class="text-sm text-gray-700">
使用模型<span class="font-medium text-gray-900">{{ currentModelDisplay }}</span>
</div>
<BaseButton variant="outline" @click="navigateTo('/app/settings')">
設定
</BaseButton>
</div>
</div>
</div>
<!-- 步驟指示器 -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div
v-for="(step, index) in steps"
:key="step.id"
class="flex items-center flex-1"
>
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all',
currentStepIndex >= index
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-600'
]"
>
{{ index + 1 }}
</div>
<span
:class="[
'ml-2 text-sm font-medium',
currentStepIndex >= index ? 'text-gray-900' : 'text-gray-500'
]"
>
{{ step.name }}
</span>
</div>
<div
v-if="index < steps.length - 1"
:class="[
'flex-1 h-0.5 mx-4',
currentStepIndex > index ? 'bg-blue-500' : 'bg-gray-200'
]"
/>
</div>
</div>
</div>
<!-- 步驟 1: 輸入故事 -->
<BaseCard v-if="currentStep === 'input'" variant="elevated" class="mb-6">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">輸入故事</h2>
</template>
<div class="flex flex-col gap-6">
<BaseTextarea
v-model="workflow.story"
label="故事內容"
placeholder="請輸入完整的故事內容..."
:rows="10"
:required="true"
/>
<div class="flex justify-end gap-4">
<BaseButton variant="primary" @click="handleNextStep" :disabled="!workflow.story.trim()">
下一步選擇風格
</BaseButton>
</div>
</div>
</BaseCard>
<!-- 步驟 2: 選擇風格 -->
<BaseCard v-if="currentStep === 'style'" variant="elevated" class="mb-6">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">選擇風格</h2>
</template>
<div class="flex flex-col gap-6">
<!-- 預設風格選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-900">預設風格</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="preset in stylePresets"
:key="preset.id"
:class="[
'p-4 border-2 rounded-xl cursor-pointer transition-all hover:shadow-lg',
workflow.style.preset === preset.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
]"
@click="selectStylePreset(preset.id)"
>
<div class="font-semibold text-gray-900 mb-1">{{ preset.name }}</div>
<div class="text-sm text-gray-600">{{ preset.description }}</div>
</div>
</div>
</div>
<!-- 或上傳圖片分析風格 -->
<div>
<label class="block mb-3 font-semibold text-gray-900">或上傳圖片分析風格</label>
<div class="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center">
<input
type="file"
accept="image/*"
@change="handleStyleImageUpload"
class="hidden"
ref="styleImageInput"
/>
<div v-if="!workflow.style.customImage" class="space-y-4">
<svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-gray-600">點擊上傳或拖放圖片</p>
<BaseButton variant="outline" @click="() => styleImageInput?.click()">
選擇圖片
</BaseButton>
</div>
<div v-else class="space-y-4">
<img :src="workflow.style.customImage" alt="風格圖片" class="max-h-48 mx-auto rounded-lg" />
<BaseButton variant="outline" @click="workflow.style.customImage = ''">
重新選擇
</BaseButton>
</div>
</div>
</div>
<!-- 節奏選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-900">節奏</label>
<select
v-model="workflow.pace"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<option value="">請選擇節奏</option>
<option v-for="pace in paceOptions" :key="pace.id" :value="pace.id">
{{ pace.name }} - {{ pace.description }}
</option>
</select>
</div>
<div class="flex justify-between gap-4">
<BaseButton variant="outline" @click="handlePrevStep">
上一步
</BaseButton>
<BaseButton variant="primary" @click="handleAnalyzeStory" :disabled="isAnalyzing">
{{ isAnalyzing ? '分析中...' : '分析故事' }}
</BaseButton>
</div>
</div>
</BaseCard>
<!-- 步驟 3: 資產管理 -->
<div v-if="currentStep === 'assets'" class="space-y-6">
<!-- 人物資產 -->
<BaseCard variant="elevated">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">人物資產</h2>
</template>
<div class="space-y-6">
<div
v-for="character in workflow.characters"
:key="character.id"
class="p-4 border border-gray-200 rounded-xl"
>
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-900">{{ character.name }}</h3>
<p class="text-sm text-gray-600">{{ character.description }}</p>
<span class="inline-block mt-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{{ character.role }}
</span>
</div>
<BaseButton variant="outline" @click="editCharacter(character.id)" class="text-sm px-3 py-1">
編輯
</BaseButton>
</div>
<!-- 三視圖預覽 -->
<div class="grid grid-cols-3 gap-4 mt-4">
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!character.frontView" class="text-gray-400 text-sm">正面</span>
<img v-else :src="character.frontView" alt="正面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" @click="generateCharacterView(character.id, 'front')" class="text-sm px-3 py-1">
生成
</BaseButton>
</div>
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!character.sideView" class="text-gray-400 text-sm">側面</span>
<img v-else :src="character.sideView" alt="側面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" size="sm" @click="generateCharacterView(character.id, 'side')">
生成
</BaseButton>
</div>
<div class="text-center">
<div class="aspect-square bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!character.backView" class="text-gray-400 text-sm">背面</span>
<img v-else :src="character.backView" alt="背面" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" size="sm" @click="generateCharacterView(character.id, 'back')">
生成
</BaseButton>
</div>
</div>
</div>
</div>
</BaseCard>
<!-- 場景資產 -->
<BaseCard variant="elevated">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">場景資產</h2>
</template>
<div class="space-y-6">
<div
v-for="scene in workflow.scenes"
:key="scene.id"
class="p-4 border border-gray-200 rounded-xl"
>
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-900">{{ scene.name }}</h3>
<p class="text-sm text-gray-600">{{ scene.description }}</p>
<span class="inline-block mt-2 px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
{{ scene.environment }}
</span>
</div>
<BaseButton variant="outline" @click="editScene(scene.id)" class="text-sm px-3 py-1">
編輯
</BaseButton>
</div>
<!-- 場景預覽 -->
<div class="mt-4">
<div class="aspect-video bg-white border-2 border-gray-200 rounded-lg flex items-center justify-center mb-2">
<span v-if="!scene.image" class="text-gray-400">場景圖片</span>
<img v-else :src="scene.image" alt="場景" class="w-full h-full object-contain rounded-lg" />
</div>
<BaseButton variant="outline" @click="generateSceneImage(scene.id)" class="text-sm px-3 py-1">
生成場景圖片
</BaseButton>
</div>
</div>
</div>
</BaseCard>
<div class="flex justify-between gap-4">
<BaseButton variant="outline" @click="handlePrevStep">
上一步
</BaseButton>
<BaseButton variant="primary" @click="handleGenerateStoryboard" :disabled="isGenerating">
{{ isGenerating ? '生成中...' : '生成分鏡表' }}
</BaseButton>
</div>
</div>
<!-- 步驟 4: 分鏡表結果 -->
<BaseCard v-if="currentStep === 'result'" variant="elevated">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">分鏡表結果</h2>
</template>
<div v-if="error" class="p-4 bg-red-50/80 backdrop-blur-sm border border-red-200 rounded-xl text-red-800 shadow-sm">
{{ error }}
</div>
<div v-else-if="workflow.result" class="space-y-4">
<div class="p-4 bg-blue-50 rounded-xl">
<h3 class="font-semibold text-gray-900 mb-2">{{ workflow.result.title }}</h3>
<div class="text-sm text-gray-700">
<p><strong>風格</strong>{{ workflow.result.style }}</p>
<p><strong>節奏</strong>{{ workflow.result.pace }}</p>
</div>
</div>
<div class="space-y-4">
<div
v-for="shot in workflow.result.shots"
:key="shot.shotNumber"
class="p-4 border border-gray-200 rounded-xl"
>
<div class="flex items-start justify-between mb-2">
<h4 class="font-semibold text-gray-900">鏡頭 {{ shot.shotNumber }}</h4>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-600"><strong>場景</strong>{{ shot.scene }}</p>
<p v-if="shot.dialogue" class="text-gray-600"><strong>對話</strong>{{ shot.dialogue }}</p>
</div>
<div>
<p class="text-gray-600"><strong>鏡頭</strong>{{ shot.camera.shot }}</p>
<p class="text-gray-600"><strong>運鏡</strong>{{ shot.camera.movement }}</p>
<p class="text-gray-600"><strong>構圖</strong>{{ shot.camera.composition }}</p>
<p class="text-gray-600"><strong>光線</strong>{{ shot.camera.lighting }}</p>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-gray-700 text-center py-16">
請完成前面的步驟以生成分鏡表
</div>
</BaseCard>
<!-- 人物編輯對話框 -->
<CharacterEditor
:is-open="isCharacterEditorOpen"
:character="editingCharacter"
@close="isCharacterEditorOpen = false"
@save="handleCharacterSave"
/>
<!-- 場景編輯對話框 -->
<SceneEditor
:is-open="isSceneEditorOpen"
:scene="editingScene"
@close="isSceneEditorOpen = false"
@save="handleSceneSave"
/>
</div>
</template>
<script setup lang="ts">
import { useAIStoryboard } from '~/composables/useAIStoryboard'
import { useAIStoryboardAnalysis } from '~/composables/useAIStoryboardAnalysis'
import { useAIImageGeneration } from '~/composables/useAIImageGeneration'
import CharacterEditor from '~/components/feature/CharacterEditor.vue'
import SceneEditor from '~/components/feature/SceneEditor.vue'
import { getTextModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL } from '~/utils/clients/gemini-models'
import { getModelInfo } from '~/utils/clients/all-models'
import { STYLE_PRESETS } from '~/utils/storyboard/styles'
import { PACE_OPTIONS } from '~/utils/storyboard/pace'
import type { StoryboardWorkflow, StoryboardStep, CharacterAsset, SceneAsset } from '~/types/storyboard'
const { generate, isLoading, error } = useAIStoryboard()
const { analyze: analyzeStory, isLoading: isAnalyzing, error: analysisError } = useAIStoryboardAnalysis()
//
const currentStep = ref<StoryboardStep>('input')
const workflow = ref<StoryboardWorkflow>({
step: 'input',
story: '',
style: {},
characters: [],
scenes: []
})
const steps = [
{ id: 'input', name: '輸入故事' },
{ id: 'style', name: '選擇風格' },
{ id: 'assets', name: '資產管理' },
{ id: 'result', name: '分鏡表結果' }
]
const currentStepIndex = computed(() => steps.findIndex(s => s.id === currentStep.value))
const stylePresets = STYLE_PRESETS
const paceOptions = PACE_OPTIONS
const styleImageInput = ref<HTMLInputElement>()
// 使
const currentModel = computed(() => getTextModel() || DEFAULT_TEXT_MODEL)
const currentModelInfo = computed(() => getModelInfo(currentModel.value))
const currentModelDisplay = computed(() => currentModelInfo.value?.displayName || currentModel.value)
function handleNextStep() {
const currentIndex = steps.findIndex(s => s.id === currentStep.value)
if (currentIndex >= 0 && currentIndex < steps.length - 1) {
const nextStep = steps[currentIndex + 1]
if (nextStep) {
currentStep.value = nextStep.id as StoryboardStep
workflow.value.step = currentStep.value
}
}
}
function handlePrevStep() {
const currentIndex = steps.findIndex(s => s.id === currentStep.value)
if (currentIndex > 0) {
const prevStep = steps[currentIndex - 1]
if (prevStep) {
currentStep.value = prevStep.id as StoryboardStep
workflow.value.step = currentStep.value
}
}
}
function selectStylePreset(presetId: string) {
workflow.value.style.preset = presetId
workflow.value.style.customImage = ''
}
function handleStyleImageUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
workflow.value.style.customImage = e.target?.result as string
workflow.value.style.preset = undefined
}
reader.readAsDataURL(file)
}
}
async function handleAnalyzeStory() {
if (!workflow.value.story.trim()) return
try {
const styleText = workflow.value.style.preset
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
: undefined
const analysis = await analyzeStory({
story: workflow.value.story,
style: styleText,
styleImage: workflow.value.style.customImage
})
workflow.value.analysis = analysis
workflow.value.characters = analysis.characters
workflow.value.scenes = analysis.scenes
if (analysis.suggestedStyle && !workflow.value.style.preset) {
workflow.value.style.analyzedStyle = analysis.suggestedStyle
}
if (analysis.suggestedPace && !workflow.value.pace) {
workflow.value.pace = analysis.suggestedPace
}
handleNextStep()
} catch (err) {
// composable
}
}
//
const editingCharacter = ref<CharacterAsset | null>(null)
const editingScene = ref<SceneAsset | null>(null)
const isCharacterEditorOpen = ref(false)
const isSceneEditorOpen = ref(false)
//
const { generateCharacterView: generateCharacterViewImage, generateSceneImage: generateSceneImageUrl, isLoading: isGeneratingImage } = useAIImageGeneration()
function editCharacter(characterId: string) {
const character = workflow.value.characters.find(c => c.id === characterId)
if (character) {
editingCharacter.value = character
isCharacterEditorOpen.value = true
}
}
function handleCharacterSave(updatedCharacter: CharacterAsset) {
const index = workflow.value.characters.findIndex(c => c.id === updatedCharacter.id)
if (index >= 0) {
workflow.value.characters[index] = updatedCharacter
}
}
async function generateCharacterView(characterId: string, view: 'front' | 'side' | 'back') {
const character = workflow.value.characters.find(c => c.id === characterId)
if (!character) return
try {
const style = workflow.value.style.preset
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
: workflow.value.style.analyzedStyle || '寫實風格'
const imageUrl = await generateCharacterViewImage(
character.name,
character.description,
view,
style
)
if (view === 'front') {
character.frontView = imageUrl
} else if (view === 'side') {
character.sideView = imageUrl
} else if (view === 'back') {
character.backView = imageUrl
}
} catch (err) {
console.error('生成人物視圖失敗:', err)
}
}
function editScene(sceneId: string) {
const scene = workflow.value.scenes.find(s => s.id === sceneId)
if (scene) {
editingScene.value = scene
isSceneEditorOpen.value = true
}
}
function handleSceneSave(updatedScene: SceneAsset) {
const index = workflow.value.scenes.findIndex(s => s.id === updatedScene.id)
if (index >= 0) {
workflow.value.scenes[index] = updatedScene
}
}
async function generateSceneImage(sceneId: string) {
const scene = workflow.value.scenes.find(s => s.id === sceneId)
if (!scene) return
try {
const style = workflow.value.style.preset
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
: workflow.value.style.analyzedStyle || '寫實風格'
const imageUrl = await generateSceneImageUrl(
scene.name,
scene.description,
style
)
scene.image = imageUrl
} catch (err) {
console.error('生成場景圖片失敗:', err)
}
}
const isGenerating = ref(false)
async function handleGenerateStoryboard() {
if (!workflow.value.story.trim()) return
isGenerating.value = true
try {
const styleText = workflow.value.style.preset
? stylePresets.find(s => s.id === workflow.value.style.preset)?.name
: (workflow.value.style.analyzedStyle || workflow.value.style.customImage
? '根據上傳圖片分析'
: undefined)
const paceText = workflow.value.pace
? paceOptions.find(p => p.id === workflow.value.pace)?.name || undefined
: undefined
const resultText = await generate({
story: workflow.value.story,
style: styleText,
pace: paceText,
styleImage: workflow.value.style.customImage
})
// JSON
const jsonMatch = resultText.match(/\{[\s\S]*\}/)
if (jsonMatch) {
workflow.value.result = JSON.parse(jsonMatch[0])
handleNextStep()
} else {
throw new Error('無法解析分鏡表結果')
}
} catch (err) {
// composable
} finally {
isGenerating.value = false
}
}
</script>

123
app/pages/app/dashboard.vue Normal file
View File

@ -0,0 +1,123 @@
<template>
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold mb-2 text-gray-900">儀表板</h1>
<div class="text-sm text-gray-700 flex flex-wrap items-center gap-2">
<span>文字<span class="font-medium text-gray-900">{{ currentTextModelDisplay }}</span></span>
<span class="text-gray-500">|</span>
<span>視覺<span class="font-medium text-gray-900">{{ currentVisionModelDisplay }}</span></span>
<span class="text-gray-500">|</span>
<span>圖像生成<span class="font-medium text-gray-900">{{ currentImageGenerationModelDisplay }}</span></span>
<span class="text-gray-500">|</span>
<span>語音<span class="font-medium text-gray-900">{{ currentTTSModelDisplay }}</span></span>
</div>
</div>
<BaseButton variant="outline" @click="() => router.push('/app/settings')">
設定
</BaseButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div
class="cursor-pointer"
@click="goToStoryboard"
>
<BaseCard
variant="elevated"
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
>
<h3 class="text-lg font-semibold mb-2 text-gray-900">AI 分鏡表</h3>
<p class="text-gray-700 text-sm">
輸入故事內容AI 自動生成專業分鏡表
</p>
</BaseCard>
</div>
<div
class="cursor-pointer"
@click="goToCamera"
>
<BaseCard
variant="elevated"
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
>
<h3 class="text-lg font-semibold mb-2 text-gray-900">AI 攝影機</h3>
<p class="text-gray-700 text-sm">
上傳圖片AI 分析鏡位與運鏡建議
</p>
</BaseCard>
</div>
<div
class="cursor-pointer"
@click="goToSoraDirector"
>
<BaseCard
variant="elevated"
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
>
<h3 class="text-lg font-semibold mb-2 text-gray-900">Sora 導演</h3>
<p class="text-gray-700 text-sm">
一句話 promptAI 生成影片規劃
</p>
</BaseCard>
</div>
<div
class="cursor-pointer"
@click="goToCutAgent"
>
<BaseCard
variant="elevated"
class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95 h-full"
>
<h3 class="text-lg font-semibold mb-2 text-gray-900">Cut Agent</h3>
<p class="text-gray-700 text-sm">
素材與劇情AI 提供剪輯建議
</p>
</BaseCard>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getTextModel, getVisionModel, getImageGenerationModel, getTTSModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, DEFAULT_IMAGE_GENERATION_MODEL, DEFAULT_TTS_MODEL } from '~/utils/clients/gemini-models'
import { getModelInfo } from '~/utils/clients/all-models'
const router = useRouter()
// 使
const currentTextModel = computed(() => getTextModel() || DEFAULT_TEXT_MODEL)
const currentVisionModel = computed(() => getVisionModel() || DEFAULT_VISION_MODEL)
const currentImageGenerationModel = computed(() => getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL)
const currentTTSModel = computed(() => getTTSModel() || DEFAULT_TTS_MODEL)
const currentTextModelInfo = computed(() => getModelInfo(currentTextModel.value))
const currentVisionModelInfo = computed(() => getModelInfo(currentVisionModel.value))
const currentImageGenerationModelInfo = computed(() => getModelInfo(currentImageGenerationModel.value))
const currentTTSModelInfo = computed(() => getModelInfo(currentTTSModel.value))
const currentTextModelDisplay = computed(() => currentTextModelInfo.value?.displayName || currentTextModel.value)
const currentVisionModelDisplay = computed(() => currentVisionModelInfo.value?.displayName || currentVisionModel.value)
const currentImageGenerationModelDisplay = computed(() => currentImageGenerationModelInfo.value?.displayName || currentImageGenerationModel.value)
const currentTTSModelDisplay = computed(() => currentTTSModelInfo.value?.displayName || currentTTSModel.value)
function goToStoryboard() {
router.push('/app/ai-storyboard')
}
function goToCamera() {
router.push('/app/ai-camera')
}
function goToSoraDirector() {
router.push('/app/sora-director')
}
function goToCutAgent() {
router.push('/app/cut-agent')
}
</script>

299
app/pages/app/settings.vue Normal file
View File

@ -0,0 +1,299 @@
<template>
<div class="max-w-4xl mx-auto px-8 py-8">
<div class="flex items-center gap-4 mb-8">
<BaseBackButton />
<h1 class="text-3xl font-bold text-gray-900">設定</h1>
</div>
<BaseCard variant="elevated" class="mb-6">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">API Key 設定</h2>
</template>
<div class="flex flex-col gap-6">
<!-- Gemini Token -->
<BaseInput
v-model="geminiToken"
label="Gemini API Key"
type="password"
placeholder="請輸入 Gemini API Key"
/>
<!-- Grok Token -->
<BaseInput
v-model="grokToken"
label="Grok API Key (選填)"
type="password"
placeholder="請輸入 Grok API Key"
/>
<p class="text-sm text-gray-700 -mt-2">
提示系統會根據您選擇的模型自動使用對應的 API Key
</p>
</div>
</BaseCard>
<!-- 模型選擇 -->
<BaseCard variant="elevated" class="mb-6">
<template #header>
<h2 class="text-xl font-semibold text-gray-900">模型選擇</h2>
</template>
<div class="flex flex-col gap-8">
<!-- 文字模型選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-800">文字生成模型</label>
<!-- Gemini 模型 -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium text-gray-700">Gemini</span>
</div>
<select
v-model="textModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Gemini 模型">
<option v-for="model in geminiTextModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<!-- Grok 模型 -->
<div>
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
<span class="text-sm font-medium text-gray-700">Grok</span>
</div>
<select
v-model="textModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Grok 模型">
<option v-for="model in grokTextModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<p v-if="currentTextModelInfo" class="mt-3 text-sm text-gray-700">
{{ currentTextModelInfo.description }}
</p>
</div>
<!-- 視覺模型選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-800">視覺分析模型</label>
<!-- Gemini 模型 -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium text-gray-700">Gemini</span>
</div>
<select
v-model="visionModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Gemini 模型">
<option v-for="model in geminiVisionModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<!-- Grok 模型 -->
<div>
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
<span class="text-sm font-medium text-gray-700">Grok</span>
</div>
<select
v-model="visionModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Grok 模型">
<option v-for="model in grokVisionModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<p v-if="currentVisionModelInfo" class="mt-3 text-sm text-gray-700">
{{ currentVisionModelInfo.description }}
</p>
</div>
<!-- 圖像生成模型選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-800">圖像生成模型</label>
<!-- Gemini 模型 -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium text-gray-700">Gemini</span>
</div>
<select
v-model="imageGenerationModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Gemini 模型">
<option v-for="model in geminiImageGenerationModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<!-- Grok 模型 -->
<div>
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
<span class="text-sm font-medium text-gray-700">Grok</span>
</div>
<select
v-model="imageGenerationModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Grok 模型">
<option v-for="model in grokImageGenerationModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<p v-if="currentImageGenerationModelInfo" class="mt-3 text-sm text-gray-700">
{{ currentImageGenerationModelInfo.description }}
</p>
</div>
<!-- TTS 模型選擇 -->
<div>
<label class="block mb-3 font-semibold text-gray-800">語音生成模型 (TTS)</label>
<!-- Gemini 模型 -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium text-gray-700">Gemini</span>
</div>
<select
v-model="ttsModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Gemini 模型">
<option v-for="model in geminiTTSModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<!-- Grok 模型 -->
<div v-if="grokTTSModels.length > 0">
<div class="flex items-center gap-2 mb-2">
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
<span class="text-sm font-medium text-gray-700">Grok</span>
</div>
<select
v-model="ttsModel"
class="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-all bg-white/90 backdrop-blur-sm text-gray-900"
>
<optgroup label="Grok 模型">
<option v-for="model in grokTTSModels" :key="model.name" :value="model.name">
{{ model.displayName }}
</option>
</optgroup>
</select>
</div>
<p v-if="currentTTSModelInfo" class="mt-3 text-sm text-gray-700">
{{ currentTTSModelInfo.description }}
</p>
</div>
<!-- 儲存按鈕 -->
<div class="flex justify-end gap-4 pt-4">
<BaseButton variant="outline" @click="handleCancel">取消</BaseButton>
<BaseButton variant="primary" @click="handleSave">儲存</BaseButton>
</div>
</div>
</BaseCard>
<!-- 成功訊息 -->
<div
v-if="showSuccess"
class="mt-4 p-4 bg-green-100/80 backdrop-blur-sm border border-green-300 rounded-xl text-green-800 shadow-lg"
>
設定已儲存
</div>
</div>
</template>
<script setup lang="ts">
import { getGeminiToken, setGeminiToken, getGrokToken, setGrokToken, getTextModel, setTextModel, getVisionModel, setVisionModel, getImageGenerationModel, setImageGenerationModel, getTTSModel, setTTSModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, DEFAULT_IMAGE_GENERATION_MODEL, DEFAULT_TTS_MODEL } from '~/utils/clients/gemini-models'
import { getModelsByCapability, getModelInfo, getModelsByProvider } from '~/utils/clients/all-models'
const geminiToken = ref(getGeminiToken() || '')
const grokToken = ref(getGrokToken() || '')
const textModel = ref(getTextModel() || DEFAULT_TEXT_MODEL)
const visionModel = ref(getVisionModel() || DEFAULT_VISION_MODEL)
const imageGenerationModel = ref(getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL)
const ttsModel = ref(getTTSModel() || DEFAULT_TTS_MODEL)
const showSuccess = ref(false)
// Provider
const allTextModels = computed(() => getModelsByCapability('text'))
const allVisionModels = computed(() => getModelsByCapability('vision'))
const allImageGenerationModels = computed(() => getModelsByCapability('imageGeneration'))
const allTTSModels = computed(() => getModelsByCapability('tts'))
const geminiTextModels = computed(() => allTextModels.value.filter(m => m.provider === 'gemini'))
const grokTextModels = computed(() => allTextModels.value.filter(m => m.provider === 'grok'))
const geminiVisionModels = computed(() => allVisionModels.value.filter(m => m.provider === 'gemini'))
const grokVisionModels = computed(() => allVisionModels.value.filter(m => m.provider === 'grok'))
const geminiImageGenerationModels = computed(() => allImageGenerationModels.value.filter(m => m.provider === 'gemini'))
const grokImageGenerationModels = computed(() => allImageGenerationModels.value.filter(m => m.provider === 'grok'))
const geminiTTSModels = computed(() => allTTSModels.value.filter(m => m.provider === 'gemini'))
const grokTTSModels = computed(() => allTTSModels.value.filter(m => m.provider === 'grok'))
function handleSave() {
setGeminiToken(geminiToken.value)
setGrokToken(grokToken.value)
setTextModel(textModel.value)
setVisionModel(visionModel.value)
setImageGenerationModel(imageGenerationModel.value)
setTTSModel(ttsModel.value)
showSuccess.value = true
setTimeout(() => {
showSuccess.value = false
}, 3000)
}
function handleCancel() {
geminiToken.value = getGeminiToken() || ''
grokToken.value = getGrokToken() || ''
textModel.value = getTextModel() || DEFAULT_TEXT_MODEL
visionModel.value = getVisionModel() || DEFAULT_VISION_MODEL
imageGenerationModel.value = getImageGenerationModel() || DEFAULT_IMAGE_GENERATION_MODEL
ttsModel.value = getTTSModel() || DEFAULT_TTS_MODEL
}
const currentTextModelInfo = computed(() => getModelInfo(textModel.value))
const currentVisionModelInfo = computed(() => getModelInfo(visionModel.value))
const currentImageGenerationModelInfo = computed(() => getModelInfo(imageGenerationModel.value))
const currentTTSModelInfo = computed(() => getModelInfo(ttsModel.value))
</script>

61
app/pages/index.vue Normal file
View File

@ -0,0 +1,61 @@
<template>
<div class="max-w-7xl mx-auto px-8 py-16">
<div class="text-center mb-16">
<h1 class="text-5xl font-bold mb-4 text-gray-900">Cut AI</h1>
<p class="text-xl text-gray-700 mb-8">
一站式 AI 影像製作工具
</p>
<BaseButton variant="primary" @click="navigateToDashboard">
開始使用
</BaseButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
<h3 class="text-xl font-semibold mb-4 text-gray-900">AI 分鏡表</h3>
<p class="text-gray-700 mb-4">
輸入故事內容AI 自動生成專業分鏡表
</p>
<BaseButton variant="outline" @click="() => navigateTo('/app/ai-storyboard')">
前往
</BaseButton>
</BaseCard>
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
<h3 class="text-xl font-semibold mb-4 text-gray-900">AI 攝影機</h3>
<p class="text-gray-700 mb-4">
上傳圖片AI 分析鏡位與運鏡建議
</p>
<BaseButton variant="outline" @click="() => navigateTo('/app/ai-camera')">
前往
</BaseButton>
</BaseCard>
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
<h3 class="text-xl font-semibold mb-4 text-gray-900">Sora 導演</h3>
<p class="text-gray-700 mb-4">
一句話 promptAI 生成影片規劃
</p>
<BaseButton variant="outline" @click="() => navigateTo('/app/sora-director')">
前往
</BaseButton>
</BaseCard>
<BaseCard variant="elevated" class="hover:shadow-2xl transition-all hover:-translate-y-1 active:scale-95">
<h3 class="text-xl font-semibold mb-4 text-gray-900">Cut Agent</h3>
<p class="text-gray-700 mb-4">
素材與劇情AI 提供剪輯建議
</p>
<BaseButton variant="outline" @click="() => navigateTo('/app/cut-agent')">
前往
</BaseButton>
</BaseCard>
</div>
</div>
</template>
<script setup lang="ts">
function navigateToDashboard() {
return navigateTo('/app/dashboard')
}
</script>

47
app/types/ai.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* AI Provider Interface
* AI Provider
*/
export interface AIProvider {
/**
*
* @param input -
* @returns
*/
generateStoryboard(input: unknown): Promise<string>
/**
*
* @param input -
* @returns
*/
analyzeCamera(input: unknown): Promise<string>
/**
*
* @param input - prompt
* @returns
*/
generateVideoPlan(input: unknown): Promise<string>
/**
*
* @param input -
* @returns
*/
generateEditSuggestion(input: unknown): Promise<string>
}
/**
* AI Provider
*/
export type AIProviderType = 'gemini' | 'grok'
/**
* AI Provider
*/
export interface AIProviderConfig {
type: AIProviderType
token: string
}

119
app/types/camera.ts Normal file
View File

@ -0,0 +1,119 @@
/**
*
*
* AI
* AI
*
* Record<string, unknown>
*/
/**
*
*
*/
export interface CameraLanguage {
/**
* Shot Size & Lens
* Extreme Long Shot (XLS), Long Shot (LS), Medium Shot (MS),
* Close-Up (CU), Extreme Close-Up (ECU), Over-the-Shoulder (OTS), POV
*/
shot: string
/**
* Camera Movement
* Pan, Tilt, Dolly In/Out, Truck, Crane, Handheld, Dolly Zoom
*/
movement: string
/**
* Composition
* Rule of Thirds, Leading Lines, Depth of Field, Dutch Angle,
* Headroom, 180-degree Rule
*/
composition: string
/**
* Lighting
* Three-Point Lighting, High Key, Low Key, Hard Light, Soft Light
*/
lighting: string
/**
* Description
*
*/
description: string
/**
*
* AI 使
* lens, focalLength, aperture, iso, colorGrading
*/
[key: string]: unknown
}
/**
*
*/
export interface StoryboardShot {
/**
*
*/
shotNumber: number
/**
*
*/
camera: CameraLanguage
/**
*
*/
scene: string
/**
*
*/
dialogue?: string
/**
*
*/
duration?: number
/**
*
*/
[key: string]: unknown
}
/**
*
*/
export interface Storyboard {
/**
*
*/
title: string
/**
*
*/
style?: string
/**
*
*/
pace?: string
/**
*
*/
shots: StoryboardShot[]
/**
*
*/
[key: string]: unknown
}

107
app/types/storyboard.ts Normal file
View File

@ -0,0 +1,107 @@
/**
*
*/
import type { Storyboard, StoryboardShot } from './camera'
/**
*
*/
export interface StylePreset {
id: string
name: string
description: string
imageUrl?: string
keywords: string[]
}
/**
*
*/
export interface PaceOption {
id: string
name: string
description: string
}
/**
*
*/
export type StoryboardStep = 'input' | 'style' | 'assets' | 'characters' | 'scenes' | 'result'
/**
*
*/
export interface StoryAnalysis {
title: string
summary: string
characters: CharacterAsset[]
scenes: SceneAsset[]
suggestedStyle?: string
suggestedPace?: string
}
/**
*
*/
export interface CharacterAsset {
id: string
name: string
description: string
role: string
// 三視圖(白色背景)
frontView?: string // base64 或 URL
sideView?: string
backView?: string
// 可編輯屬性
clothing?: string
accessories?: string[]
colors?: {
hair?: string
skin?: string
clothing?: string
}
// 可擴充欄位
[key: string]: unknown
}
/**
*
*/
export interface SceneAsset {
id: string
name: string
description: string
environment: string
// 場景圖片(白色背景)
image?: string // base64 或 URL
// 可編輯屬性
lighting?: string
props?: string[]
colors?: {
primary?: string
secondary?: string
accent?: string
}
// 可擴充欄位
[key: string]: unknown
}
/**
*
*/
export interface StoryboardWorkflow {
step: StoryboardStep
story: string
style: {
preset?: string
customImage?: string
analyzedStyle?: string
}
pace?: string
analysis?: StoryAnalysis
characters: CharacterAsset[]
scenes: SceneAsset[]
result?: Storyboard
}

232
app/utils/ai/prompts.ts Normal file
View File

@ -0,0 +1,232 @@
/**
* AI Prompt
* Prompt
*/
export interface StoryboardInput {
story: string
style?: string
pace?: string
styleImage?: string // base64 圖片用於風格分析
}
export interface StoryAnalysisInput {
story: string
style?: string
styleImage?: string
}
export interface AssetGenerationInput {
story: string
analysis: string
characterName?: string
sceneName?: string
}
export interface CameraAnalysisInput {
image?: string
description?: string
}
export interface VideoPlanInput {
prompt: string
}
export interface EditSuggestionInput {
materials: string
storyline: string
}
/**
* Prompt
*/
export function buildStoryAnalysisPrompt(input: StoryAnalysisInput): string {
const { story, style, styleImage } = input
let prompt = `請分析以下故事,提取關鍵資訊。請以 JSON 格式回傳,結構如下:
{
"title": "故事標題",
"summary": "故事摘要",
"characters": [
{
"id": "character_1",
"name": "角色名稱",
"description": "角色描述",
"role": "角色定位(主角、配角等)"
}
],
"scenes": [
{
"id": "scene_1",
"name": "場景名稱",
"description": "場景描述",
"environment": "環境設定"
}
],
"suggestedStyle": "建議的視覺風格",
"suggestedPace": "建議的節奏"
}
${story}`
if (style) {
prompt += `\n\n風格要求${style}`
}
if (styleImage) {
prompt += `\n\n已提供風格參考圖片請分析圖片中的視覺風格特徵並應用。`
}
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
return prompt
}
/**
* Prompt
*/
export function buildCharacterViewPrompt(input: AssetGenerationInput): string {
return `請為角色「${input.characterName || '角色'}」生成白色背景的三視圖(正面、側面、背面)。
${input.analysis}
JSON
{
"characterName": "角色名稱",
"frontView": "正面視圖描述",
"sideView": "側面視圖描述",
"backView": "背面視圖描述",
"description": "角色詳細描述"
}
JSON `
}
/**
* Prompt
*/
export function buildScenePrompt(input: AssetGenerationInput): string {
return `請為場景「${input.sceneName || '場景'}」生成白色背景的場景圖。
${input.analysis}
JSON
{
"sceneName": "場景名稱",
"description": "場景詳細描述",
"environment": "環境設定",
"lighting": "光線建議",
"props": ["道具1", "道具2"],
"imageDescription": "場景圖片描述"
}
JSON `
}
/**
* Prompt
*/
export function buildStoryboardPrompt(input: StoryboardInput): string {
const { story, style, pace, styleImage } = input
let prompt = `請為以下故事生成分鏡表。請以 JSON 格式回傳,結構如下:
{
"title": "標題",
"style": "風格描述",
"pace": "節奏描述",
"shots": [
{
"shotNumber": 1,
"camera": {
"shot": "鏡頭類型Extreme Long Shot, Long Shot, Medium Shot, Close-Up 等)",
"movement": "運鏡方式Pan, Tilt, Dolly In/Out, Truck, Crane, Handheld 等)",
"composition": "構圖方式Rule of Thirds, Leading Lines, Depth of Field 等)",
"lighting": "光線設定Three-Point Lighting, High Key, Low Key 等)",
"description": "鏡頭詳細描述"
},
"scene": "場景描述",
"dialogue": "對話或旁白(選填)",
"duration":
}
]
}
${story}`
if (style) {
prompt += `\n\n風格要求${style}`
}
if (pace) {
prompt += `\n\n節奏要求${pace}`
}
if (styleImage) {
prompt += `\n\n已提供風格參考圖片請參考圖片中的視覺風格。`
}
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
return prompt
}
/**
* Prompt
*/
export function buildCameraAnalysisPrompt(input: CameraAnalysisInput): string {
let prompt = `請分析以下影像的攝影機鏡位與運鏡建議。請以 JSON 格式回傳,結構如下:
{
"camera": {
"shot": "建議的鏡頭類型",
"movement": "建議的運鏡方式",
"composition": "構圖分析",
"lighting": "光線分析",
"description": "詳細分析與建議"
}
}`
if (input.image) {
prompt += `\n\n圖片已上傳base64 或 URL`
}
if (input.description) {
prompt += `\n\n影像描述${input.description}`
}
prompt += `\n\n請確保回傳的是有效的 JSON 格式,可以直接被解析。`
return prompt
}
/**
* Prompt
*/
export function buildVideoPlanPrompt(input: VideoPlanInput): string {
return `請為以下 prompt 生成詳細的影片規劃。請以結構化的文字格式回傳,包含:
-
-
-
-
Prompt${input.prompt}
`
}
/**
* Prompt
*/
export function buildEditSuggestionPrompt(input: EditSuggestionInput): string {
return `請根據以下素材和劇情,提供剪輯建議。請以結構化的文字格式回傳,包含:
-
-
-
-
${input.materials}
${input.storyline}
`
}

View File

@ -0,0 +1,110 @@
/**
*
* Gemini Grok
*/
import { GEMINI_MODELS, type GeminiModel } from './gemini-models'
import { GROK_MODELS, type GrokModel } from './grok-models'
export interface UnifiedModel {
name: string
displayName: string
description: string
provider: 'gemini' | 'grok'
capabilities: {
text: boolean
vision: boolean
audio: boolean
video: boolean
imageGeneration: boolean
tts: boolean
}
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts' | 'robotics'
}
/**
* Gemini
*/
function convertGeminiModel(model: GeminiModel): UnifiedModel {
return {
name: model.name,
displayName: model.displayName,
description: model.description,
provider: 'gemini',
capabilities: model.capabilities,
category: model.category
}
}
/**
* Grok
*/
function convertGrokModel(model: GrokModel): UnifiedModel {
return {
name: model.name,
displayName: model.displayName,
description: model.description,
provider: 'grok',
capabilities: model.capabilities,
category: model.category
}
}
/**
* Gemini + Grok
*/
export const ALL_MODELS: UnifiedModel[] = [
...GEMINI_MODELS.map(convertGeminiModel),
...GROK_MODELS.map(convertGrokModel)
]
/**
*
*/
export function getModelsByCapability(capability: keyof UnifiedModel['capabilities']): UnifiedModel[] {
return ALL_MODELS.filter(m => m.capabilities[capability] === true)
}
/**
* Provider
*/
export function getModelsByProvider(provider: 'gemini' | 'grok'): UnifiedModel[] {
return ALL_MODELS.filter(m => m.provider === provider)
}
/**
*
*/
export function getModelsByCategory(category: UnifiedModel['category']): UnifiedModel[] {
return ALL_MODELS.filter(m => m.category === category)
}
/**
*
*/
export function getModelInfo(modelName: string): UnifiedModel | undefined {
return ALL_MODELS.find(m => m.name === modelName)
}
/**
* Provider
*/
export function getProviderForModel(modelName: string): 'gemini' | 'grok' {
const model = getModelInfo(modelName)
if (model) {
return model.provider
}
// 如果找不到模型,根據名稱判斷
if (modelName.toLowerCase().includes('grok')) {
return 'grok'
}
return 'gemini'
}
/**
* Provider
*/
export function getModelProvider(modelName: string): 'gemini' | 'grok' {
return getProviderForModel(modelName)
}

View File

@ -0,0 +1,271 @@
/**
* Gemini API
* Google Gemini API
* https://ai.google.dev/gemini-api/docs?hl=zh-tw
*/
export interface GeminiModel {
name: string
displayName: string
description: string
capabilities: {
text: boolean
vision: boolean
audio: boolean
video: boolean
imageGeneration: boolean
tts: boolean
}
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts' | 'robotics'
}
/**
* Gemini
* 2025
*/
export const GEMINI_MODELS: GeminiModel[] = [
// Gemini 3 系列
{
name: 'gemini-3-pro',
displayName: 'Gemini 3 Pro',
description: 'Google 最聰明的模型,全球最出色的多模態理解模型,建立在最先進的推論技術基礎',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// Gemini 2.5 系列
{
name: 'gemini-2.5-pro',
displayName: 'Gemini 2.5 Pro',
description: 'Google 強大的推理模型,擅長程式設計和複雜的推理工作',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-2.5-pro-tts',
displayName: 'Gemini 2.5 Pro TTS',
description: 'Gemini 2.5 模型變體,具備原生文字轉語音 (TTS) 功能',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: true
},
category: 'tts'
},
{
name: 'gemini-2.5-flash',
displayName: 'Gemini 2.5 Flash',
description: '表現最均衡的模型,脈絡窗口達 100 萬個詞元,執行更多工作',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-2.5-flash-lite',
displayName: 'Gemini 2.5 Flash-Lite',
description: '多模態模型,兼具速度和成本效益,效能優異,適合處理高頻率工作',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-2.5-flash-image',
displayName: 'Gemini 2.5 Flash Image',
description: '使用原生圖像生成功能,可生成及編輯高度情境化的圖片',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: true,
tts: false
},
category: 'image-generation'
},
// Gemini 1.5 系列(向後相容)
{
name: 'gemini-1.5-pro-latest',
displayName: 'Gemini 1.5 Pro (Latest)',
description: '最新版本的 Gemini 1.5 Pro支援文字、圖像、音頻、視頻多模態處理',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-1.5-pro',
displayName: 'Gemini 1.5 Pro',
description: 'Gemini 1.5 Pro支援文字、圖像、音頻、視頻多模態處理',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-1.5-flash-latest',
displayName: 'Gemini 1.5 Flash (Latest)',
description: '最新版本的 Gemini 1.5 Flash輕量級快速響應支援多模態',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'gemini-1.5-flash',
displayName: 'Gemini 1.5 Flash',
description: 'Gemini 1.5 Flash輕量級快速響應支援多模態',
capabilities: {
text: true,
vision: true,
audio: true,
video: true,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// 其他模型
{
name: 'gemini-pro',
displayName: 'Gemini Pro',
description: 'Gemini Pro支援文字和圖像處理',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// 機器人模型
{
name: 'gemini-robotics-er-1.5',
displayName: 'Gemini Robotics-ER 1.5',
description: '視覺語言模型 (VLM),可將 Gemini 的代理功能帶入機器人領域,在實體世界中進行進階推理',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'robotics'
}
]
/**
*
*/
export const DEFAULT_TEXT_MODEL = 'gemini-2.5-flash'
/**
*
*/
export const DEFAULT_VISION_MODEL = 'gemini-2.5-flash'
/**
*
*/
export const DEFAULT_AUDIO_MODEL = 'gemini-2.5-flash'
/**
*
*/
export const DEFAULT_IMAGE_GENERATION_MODEL = 'gemini-2.5-flash-image'
/**
* TTS
*/
export const DEFAULT_TTS_MODEL = 'gemini-2.5-pro-tts'
/**
*
*/
export function getModelForTask(
task: 'text' | 'vision' | 'audio' | 'video' | 'image-generation' | 'tts'
): string {
switch (task) {
case 'text':
return DEFAULT_TEXT_MODEL
case 'vision':
return DEFAULT_VISION_MODEL
case 'audio':
return DEFAULT_AUDIO_MODEL
case 'video':
return DEFAULT_VISION_MODEL // 視頻使用視覺模型
case 'image-generation':
return DEFAULT_IMAGE_GENERATION_MODEL
case 'tts':
return DEFAULT_TTS_MODEL
default:
return DEFAULT_TEXT_MODEL
}
}
/**
*
*/
export function getModelsByCategory(category: GeminiModel['category']): GeminiModel[] {
return GEMINI_MODELS.filter(model => model.category === category)
}
/**
*
*/
export function getModelsByCapability(capability: keyof GeminiModel['capabilities']): GeminiModel[] {
return GEMINI_MODELS.filter(model => model.capabilities[capability] === true)
}
/**
*
*/
export function getModelInfo(modelName: string): GeminiModel | undefined {
return GEMINI_MODELS.find(m => m.name === modelName)
}

131
app/utils/clients/gemini.ts Normal file
View File

@ -0,0 +1,131 @@
/**
* Gemini Client
* Gemini API
* localStorage token
*/
import type { AIProvider } from '~/types/ai'
import { getGeminiToken, getTextModel, getVisionModel } from '~/utils/storage'
import { DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, getModelForTask } from './gemini-models'
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta'
/**
* Gemini API Client
*/
export class GeminiClient implements AIProvider {
private token: string | null
constructor() {
this.token = getGeminiToken()
}
/**
* API Token
*/
private getToken(): string {
const token = getGeminiToken()
if (!token) {
throw new Error('Gemini API Token 未設定,請至設定頁面輸入')
}
return token
}
/**
* Gemini API
* @param prompt -
* @param model - 使 DEFAULT_TEXT_MODEL
* @param parts -
*/
private async callAPI(
prompt: string,
model?: string,
parts?: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }>
): Promise<string> {
// 如果沒有指定模型,使用儲存的模型或預設模型
const selectedModel = model || getTextModel() || DEFAULT_TEXT_MODEL
const token = this.getToken()
const url = `${GEMINI_API_BASE}/models/${selectedModel}:generateContent?key=${token}`
// 組裝 parts
const contentParts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = []
if (prompt) {
contentParts.push({ text: prompt })
}
if (parts) {
contentParts.push(...parts)
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{
parts: contentParts
}]
})
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
}
const data = await response.json()
return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
}
/**
*
*/
async generateStoryboard(input: unknown): Promise<string> {
// Prompt 組裝將由 utils/ai 處理,這裡只負責呼叫 API
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt)
}
/**
*
* base64 URL
*/
async analyzeCamera(input: unknown): Promise<string> {
const model = getVisionModel() || DEFAULT_VISION_MODEL
// 如果 input 是物件且包含圖像
if (typeof input === 'object' && input !== null) {
const inputObj = input as { prompt?: string; image?: { mimeType: string; data: string } }
const prompt = inputObj.prompt || '請分析這張圖片的攝影機鏡位與運鏡建議'
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = []
if (inputObj.image) {
parts.push({ inlineData: inputObj.image })
}
return this.callAPI(prompt, model, parts)
}
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt, model)
}
/**
*
*/
async generateVideoPlan(input: unknown): Promise<string> {
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt)
}
/**
*
*/
async generateEditSuggestion(input: unknown): Promise<string> {
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt)
}
}

View File

@ -0,0 +1,217 @@
/**
* Grok API
* x.ai
*/
export interface GrokModel {
name: string
displayName: string
description: string
capabilities: {
text: boolean
vision: boolean
audio: boolean
video: boolean
imageGeneration: boolean
tts: boolean
}
category: 'text' | 'vision' | 'multimodal' | 'image-generation' | 'video-generation' | 'audio' | 'tts'
}
/**
* Grok
* x.ai
*/
export const GROK_MODELS: GrokModel[] = [
// Grok 4.1 系列
{
name: 'grok-4-1-fast-reasoning',
displayName: 'Grok 4.1 Fast Reasoning',
description: 'Grok 4.1 Fast Reasoning 模型支援文字和圖像具備推理能力Context: 2,000,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'grok-4-1-fast-non-reasoning',
displayName: 'Grok 4.1 Fast Non-Reasoning',
description: 'Grok 4.1 Fast Non-Reasoning 模型支援文字和圖像Context: 2,000,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// Grok 4 系列
{
name: 'grok-4-fast-reasoning',
displayName: 'Grok 4 Fast Reasoning',
description: 'Grok 4 Fast Reasoning 模型支援文字和圖像具備推理能力Context: 2,000,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'grok-4-fast-non-reasoning',
displayName: 'Grok 4 Fast Non-Reasoning',
description: 'Grok 4 Fast Non-Reasoning 模型支援文字和圖像Context: 2,000,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'grok-4-0709',
displayName: 'Grok 4 (0709)',
description: 'Grok 4 模型支援文字和圖像Context: 256,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// Grok 3 系列
{
name: 'grok-3',
displayName: 'Grok 3',
description: 'Grok 3 模型支援文字和圖像Context: 131,072',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
{
name: 'grok-3-mini',
displayName: 'Grok 3 Mini',
description: 'Grok 3 Mini 模型支援文字和圖像Context: 131,072',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// Grok Code 系列
{
name: 'grok-code-fast-1',
displayName: 'Grok Code Fast 1',
description: 'Grok Code Fast 1 模型專為程式碼優化支援文字和圖像Context: 256,000',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'multimodal'
},
// Grok 2 系列
{
name: 'grok-2-vision-1212',
displayName: 'Grok 2 Vision (1212)',
description: 'Grok 2 Vision 模型支援文字和圖像理解Context: 32,768',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: false,
tts: false
},
category: 'vision'
},
// 圖像生成模型
{
name: 'grok-2-image-1212',
displayName: 'Grok 2 Image (1212)',
description: 'Grok 2 Image 模型,支援圖像生成,支援文字和圖像輸入',
capabilities: {
text: true,
vision: true,
audio: false,
video: false,
imageGeneration: true,
tts: false
},
category: 'image-generation'
}
]
/**
*
*/
export const DEFAULT_GROK_TEXT_MODEL = 'grok-4-1-fast-reasoning'
/**
*
*/
export const DEFAULT_GROK_VISION_MODEL = 'grok-4-1-fast-reasoning'
/**
*
*/
export const DEFAULT_GROK_IMAGE_GENERATION_MODEL = 'grok-2-image-1212'
/**
*
*/
export function getGrokModelInfo(modelName: string): GrokModel | undefined {
return GROK_MODELS.find(m => m.name === modelName)
}
/**
* Grok
*/
export function getGrokModelForTask(
task: 'text' | 'vision' | 'audio' | 'video' | 'image-generation' | 'tts'
): string {
switch (task) {
case 'text':
return DEFAULT_GROK_TEXT_MODEL
case 'vision':
return DEFAULT_GROK_VISION_MODEL
case 'image-generation':
return DEFAULT_GROK_IMAGE_GENERATION_MODEL
case 'audio':
case 'video':
case 'tts':
default:
return DEFAULT_GROK_TEXT_MODEL
}
}

142
app/utils/clients/grok.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* Grok Client
* Gemini
*
*/
import type { AIProvider } from '~/types/ai'
import { getGrokToken, getTextModel, getVisionModel } from '~/utils/storage'
import { DEFAULT_GROK_TEXT_MODEL, DEFAULT_GROK_VISION_MODEL, getGrokModelForTask } from './grok-models'
const GROK_API_BASE = 'https://api.x.ai/v1'
/**
* Grok API Client
*/
export class GrokClient implements AIProvider {
/**
* API Token
*/
private getToken(): string {
const token = getGrokToken()
if (!token) {
throw new Error('Grok API Token 未設定,請至設定頁面輸入')
}
return token
}
/**
* Grok API
* @param prompt -
* @param model - 使 grok-2
* @param parts -
*/
private async callAPI(
prompt: string,
model: string = DEFAULT_GROK_TEXT_MODEL,
parts?: Array<{ text?: string; image_url?: { url: string } }>
): Promise<string> {
const token = this.getToken()
const url = `${GROK_API_BASE}/chat/completions`
// 組裝 messages
const messages: Array<{ role: string; content: Array<{ type: string; text?: string; image_url?: { url: string } }> }> = []
const contentParts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
if (prompt) {
contentParts.push({ type: 'text', text: prompt })
}
if (parts) {
parts.forEach(part => {
if (part.image_url) {
contentParts.push({ type: 'image_url', image_url: part.image_url })
}
})
}
messages.push({
role: 'user',
content: contentParts
})
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
model,
messages
})
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
}
const data = await response.json()
return data.choices?.[0]?.message?.content || ''
}
/**
*
*/
async generateStoryboard(input: unknown): Promise<string> {
// 如果選擇的模型是 Grok 模型,使用選擇的模型;否則使用預設
const selectedModel = getTextModel()
const model = selectedModel && selectedModel.includes('grok')
? selectedModel
: getGrokModelForTask('text')
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt, model)
}
/**
*
*
*/
async analyzeCamera(input: unknown): Promise<string> {
// 如果選擇的模型是 Grok 模型,使用選擇的模型;否則使用預設
const selectedModel = getVisionModel()
const model = selectedModel && selectedModel.includes('grok')
? selectedModel
: getGrokModelForTask('vision')
// 如果 input 是物件且包含圖像
if (typeof input === 'object' && input !== null) {
const inputObj = input as { prompt?: string; image?: { url: string } }
const prompt = inputObj.prompt || '請分析這張圖片的攝影機鏡位與運鏡建議'
const parts: Array<{ text?: string; image_url?: { url: string } }> = []
if (inputObj.image) {
parts.push({ image_url: inputObj.image })
}
return this.callAPI(prompt, model, parts)
}
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt, model)
}
/**
*
*/
async generateVideoPlan(input: unknown): Promise<string> {
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt)
}
/**
*
*/
async generateEditSuggestion(input: unknown): Promise<string> {
const prompt = typeof input === 'string' ? input : JSON.stringify(input)
return this.callAPI(prompt)
}
}

View File

@ -0,0 +1,54 @@
/**
* Gemini
* Google API
*/
import { getGeminiToken } from '~/utils/storage'
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1'
/**
*
*/
export async function listAvailableModels(): Promise<Array<{
name: string
displayName: string
description: string
supportedGenerationMethods: string[]
}>> {
const token = getGeminiToken()
if (!token) {
throw new Error('Gemini API Token 未設定,請至設定頁面輸入')
}
const url = `${GEMINI_API_BASE}/models?key=${token}`
try {
const response = await fetch(url)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
throw new Error(error.error?.message || `API 錯誤: ${response.status}`)
}
const data = await response.json()
// 過濾出支援 generateContent 的模型
const models = (data.models || [])
.filter((model: any) =>
model.supportedGenerationMethods?.includes('generateContent')
)
.map((model: any) => ({
name: model.name.replace('models/', ''),
displayName: model.displayName || model.name,
description: model.description || '',
supportedGenerationMethods: model.supportedGenerationMethods || []
}))
return models
} catch (error) {
console.error('取得模型列表失敗:', error)
throw error
}
}

136
app/utils/storage.ts Normal file
View File

@ -0,0 +1,136 @@
/**
* localStorage
* AI Token Provider
*/
const STORAGE_KEYS = {
GEMINI_TOKEN: 'ai_token_gemini',
GROK_TOKEN: 'ai_token_grok',
PROVIDER: 'ai_provider',
TEXT_MODEL: 'ai_text_model',
VISION_MODEL: 'ai_vision_model',
IMAGE_GENERATION_MODEL: 'ai_image_generation_model',
TTS_MODEL: 'ai_tts_model'
} as const
/**
* Gemini Token
*/
export function getGeminiToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.GEMINI_TOKEN)
}
/**
* Gemini Token
*/
export function setGeminiToken(token: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.GEMINI_TOKEN, token)
}
/**
* Grok Token
*/
export function getGrokToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.GROK_TOKEN)
}
/**
* Grok Token
*/
export function setGrokToken(token: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.GROK_TOKEN, token)
}
/**
* Provider
*/
export function getProvider(): 'gemini' | 'grok' {
if (typeof window === 'undefined') return 'gemini'
const provider = localStorage.getItem(STORAGE_KEYS.PROVIDER)
return (provider === 'grok' ? 'grok' : 'gemini') as 'gemini' | 'grok'
}
/**
* Provider
*/
export function setProvider(provider: 'gemini' | 'grok'): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.PROVIDER, provider)
}
/**
* Provider Token
*/
export function getCurrentProviderToken(): string | null {
const provider = getProvider()
return provider === 'gemini' ? getGeminiToken() : getGrokToken()
}
/**
*
*/
export function getTextModel(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.TEXT_MODEL)
}
/**
*
*/
export function setTextModel(model: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.TEXT_MODEL, model)
}
/**
*
*/
export function getVisionModel(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.VISION_MODEL)
}
/**
*
*/
export function setVisionModel(model: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.VISION_MODEL, model)
}
/**
*
*/
export function getImageGenerationModel(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.IMAGE_GENERATION_MODEL)
}
/**
*
*/
export function setImageGenerationModel(model: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.IMAGE_GENERATION_MODEL, model)
}
/**
* TTS
*/
export function getTTSModel(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(STORAGE_KEYS.TTS_MODEL)
}
/**
* TTS
*/
export function setTTSModel(model: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEYS.TTS_MODEL, model)
}

View File

@ -0,0 +1,49 @@
/**
*
*/
import type { PaceOption } from '~/types/storyboard'
/**
*
*/
export const PACE_OPTIONS: PaceOption[] = [
{
id: 'very-slow',
name: '非常緩慢',
description: '靜態鏡頭為主,長時間停留,適合情感表達'
},
{
id: 'slow',
name: '緩慢',
description: '平穩節奏,從容不迫,適合敘事'
},
{
id: 'moderate',
name: '中等',
description: '平衡節奏,標準剪輯速度'
},
{
id: 'fast',
name: '快速',
description: '快速剪輯,動態感強,適合動作場景'
},
{
id: 'very-fast',
name: '非常快速',
description: '極速剪輯,緊張刺激,適合高潮場景'
},
{
id: 'variable',
name: '變化',
description: '根據劇情需要動態調整節奏'
}
]
/**
*
*/
export function getPaceOption(id: string): PaceOption | undefined {
return PACE_OPTIONS.find(p => p.id === id)
}

View File

@ -0,0 +1,67 @@
/**
*
*/
import type { StylePreset } from '~/types/storyboard'
/**
*
*/
export const STYLE_PRESETS: StylePreset[] = [
{
id: 'simpsons',
name: '辛普森家庭',
description: '美式卡通風格,鮮豔色彩,簡化線條',
keywords: ['cartoon', 'yellow', 'simplified', 'vibrant']
},
{
id: 'anime',
name: '日式動漫',
description: '日式動畫風格,大眼睛,精緻細節',
keywords: ['anime', 'manga', 'detailed', 'expressive']
},
{
id: 'pixar',
name: '皮克斯',
description: '3D 動畫風格,寫實光影,豐富細節',
keywords: ['3d', 'realistic', 'lighting', 'detailed']
},
{
id: 'minimalist',
name: '極簡風格',
description: '簡約線條,少數色彩,乾淨設計',
keywords: ['minimal', 'clean', 'simple', 'geometric']
},
{
id: 'noir',
name: '黑色電影',
description: '高對比黑白,戲劇性光影,復古氛圍',
keywords: ['black', 'white', 'dramatic', 'vintage']
},
{
id: 'cyberpunk',
name: '賽博龐克',
description: '霓虹色彩,未來科技,都市夜景',
keywords: ['neon', 'futuristic', 'tech', 'urban']
},
{
id: 'watercolor',
name: '水彩風格',
description: '柔和色彩,流動筆觸,藝術感',
keywords: ['watercolor', 'soft', 'artistic', 'flowing']
},
{
id: 'realistic',
name: '寫實風格',
description: '真實光影,細節豐富,電影級質感',
keywords: ['realistic', 'photorealistic', 'cinematic', 'detailed']
}
]
/**
*
*/
export function getStylePreset(id: string): StylePreset | undefined {
return STYLE_PRESETS.find(s => s.id === id)
}

170
doc/CURSOR_HIGHEST_RULES.md Normal file
View File

@ -0,0 +1,170 @@
# Cut AI — Frontend Specification (Nuxt 4 · Gemini-First)
## 1. 專案定位
Cut AI 是一個「一站式 AI 影像製作工具」的前端應用。
本專案以 **真實 AIGemini即時產出** 為核心,不使用 mock data。
核心原則:
- 真實 AI 結果優先
- 架構穩定、可長期演進
- 前端作為 AI Orchestrator而非單純 UI
---
## 2. 既有技術基底(已鎖定)
```json
{
"nuxt": "^4.2.2",
"vue": "^3.5.x",
"vue-router": "^4.x"
}
```
---
## 3. Dependency 使用政策(兩階段制)
### 3.1 規劃階段Planning Phase
- 允許「討論」是否需要新增 dependency
- 僅限提出建議與理由
- **不得實際安裝或引入**
### 3.2 實作階段Execution Phase
- ❌ 禁止新增任何 dependency
- ❌ 禁止隱性引入UI library / helper library
- ✅ 僅使用既有 dependency 與原生能力
> 一旦進入實作階段dependency 清單視為「鎖定狀態」。
---
## 4. AI Provider 策略
### Primary
- **Gemini API**
- 預設唯一啟用
### Secondary預留
- Grok API
- 僅保留 client 與切換能力,不預設啟用
---
## 5. Token 管理(前端)
- Token 由使用者於設定頁手動輸入
- 儲存於 localStorage
- `ai_token_gemini`
- `ai_token_grok`
- 不 hardcode token
- 不驗證 token 正確性(由 API 回傳錯誤)
---
## 6. 專案目錄結構(建議)
```
/components
/base
/cards
/layout
/pages
index.vue
/app
dashboard.vue
ai-storyboard.vue
ai-camera.vue
sora-director.vue
cut-agent.vue
settings.vue
/composables
/utils
/types
```
---
## 7. 鏡頭語言系統Camera Language System
鏡頭語言為 **AI 知識層**,非 UI 邏輯。
前端不硬編、不限制,僅結構化呈現 Gemini 的選擇。
### 7.1 鏡頭與景別Shot Size & Lens
- Extreme Long Shot (XLS / ELS)
- Long Shot (LS)
- Medium Shot (MS)
- Close-Up (CU)
- Extreme Close-Up (ECU)
- Over-the-Shoulder Shot (OTS)
- Point of View (POV)
### 7.2 運鏡Camera Movement
- Pan
- Tilt
- Dolly In / Dolly Out
- Truck / Tracking Shot
- Crane / Boom
- Handheld
- Dolly Zoom / Vertigo Effect
### 7.3 分鏡構圖Composition & Storyboarding
- Rule of Thirds
- Leading Lines
- Depth of FieldShallow / Deep
- Dutch Angle / Canted Angle
- Headroom / Look Room
- 180-degree Rule
### 7.4 光線Lighting & Tone
- Three-Point LightingKey / Fill / Back
- High Key
- Low Key
- Hard Light
- Soft Light
- Practical Light
---
## 8. 頁面功能規格
### 8.1 Landing Page/pages/index.vue
- 專案介紹
- 功能卡片(靜態)
- CTA → Dashboard
### 8.2 Dashboard/pages/app/dashboard.vue
- 工具入口卡片
- 不顯示 AI 結果
### 8.3 Settings/pages/app/settings.vue
- Gemini API Key
- Grok API Keyoptional
- Provider 切換
### 8.4 AI 分鏡表
- 輸入故事、風格、節奏
- Gemini 回傳真實分鏡結果
- 以卡片顯示
### 8.5 AI 攝影機
- 上傳圖片
- Gemini 回傳鏡位與運鏡建議
### 8.6 Sora 導演
- 一句話 prompt
- Gemini 回傳影片規劃文字
### 8.7 Cut Agent
- 素材 + 劇情
- Gemini 回傳剪輯建議
---
## 9. UI 原則
- 卡片導向
- 可讀性優先
- 不追求動畫
- 結構穩定、可擴充

121
doc/CUT_AI_FRONTEND_SPEC.md Normal file
View File

@ -0,0 +1,121 @@
# Cursor 開發最高準則Nuxt 4 · Gemini Direct
## 1. 前提
- 專案已存在
- 使用 Nuxt 4
- 使用真實 Gemini API
- Token 由使用者於設定頁輸入
---
## 2. Dependency 規則
### 規劃階段
- 允許討論是否需要新增 dependency
- 需說明必要性與替代方案
### 實作階段(預設)
- ❌ 不新增任何 dependency
- ❌ 不安裝任何套件
- ❌ 不隱性引入 library
---
## 3. Component 拆解原則Token 最小化)
只允許三層:
### Base
- BaseButton
- BaseInput
- BaseTextarea
- BaseCard
### Feature
- ToolCard
- SceneCard
- CameraShotCard
### Page
- pages/*.vue
- 僅組合元件與呼叫 composable
---
## 4. 拆 component 的唯一判斷
是否會被兩個以上頁面重用:
- 是 → 拆
- 否 → 留在頁面
---
## 5. AI 呼叫規則
- UI 不得直接呼叫 Gemini
- UI 只呼叫 composable
- Prompt 組裝只存在於 utils/ai
---
## 6. AI Provider 架構
### Interface/types/ai.ts
```ts
export interface AIProvider {
generateStoryboard(input: unknown): Promise<string>
analyzeCamera(input: unknown): Promise<string>
generateVideoPlan(input: unknown): Promise<string>
}
```
### Gemini Client
- 從 localStorage 讀取 token
- 單一責任:呼叫 API
### Grok Client
- 與 Gemini 相同介面
- 預設不啟用
### Provider Selector
- 依設定頁選擇 provider
- 預設 Gemini
---
## 7. 鏡頭語言處理規則
- 不 hardcode 鏡頭語言
- 不寫 enum
- 不驗證 AI 選擇是否合理
- UI 僅顯示結構化結果
建議結構:
```ts
{
shot: string
movement: string
composition: string
lighting: string
description: string
}
```
---
## 8. Cursor 輸出限制
- 一次只產生一個檔案
- 不跨檔案修改
- 不自動優化
- 不補未使用功能
---
## 9. 開發順序(不可跳)
1. Settings Page
2. Gemini Client
3. Provider Selector
4. AI 分鏡表
5. 其他工具頁

6
nuxt.config.ts Normal file
View File

@ -0,0 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss']
})

11863
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "nuxt-app",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"nuxt": "^4.2.2",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

16
tailwind.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./app/components/**/*.{js,vue,ts}',
'./app/layouts/**/*.vue',
'./app/pages/**/*.vue',
'./app/plugins/**/*.{js,ts}',
'./app/app.vue',
'./app/error.vue',
],
theme: {
extend: {},
},
plugins: [],
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}