feat: 实现自建情景功能
## 功能概述 - 用户可创建、编辑、删除自定义情景 - 支持自定义情景名称、图标、描述、Prompt、首句引导 - 完整的权限隔离,用户只能管理自己的情景 - 深度集成 Eino 框架,动态加载自建情景 Prompt ## 后端实现 ### 数据库 - 新增 user_scenarios 表 - 支持用户配额(最多 20 个) - 字段验证:description 可选,prompt 最小 10 字符 ### API - GET /api/scenarios - 获取用户情景列表 - POST /api/scenarios - 创建情景 - GET /api/scenarios/:id - 获取详情 - PATCH /api/scenarios/:id - 更新情景 - DELETE /api/scenarios/:id - 删除情景 ### Eino 集成 - PipelineState 添加 UserID 字段 - nodes_history 动态加载用户自建情景 - GetScenarioPrompt 支持自建情景优先级 ## 前端实现 ### 组件 - CreateScenarioModal - 创建情景对话框 - EditScenarioModal - 编辑情景对话框 - ConfigPanel 改造 - 分组显示系统预置和自建情景 ### Hook - useScenarios - 合并系统和自建情景,提供 CRUD 接口 ### 国际化 - 中文、英文、日文翻译支持 ## 问题修复 - 修复 CORS 问题:使用 Vite 代理 - 统一验证规则:description 可选,prompt 最小 10 字符 - 修复数据库约束:使用 NULLIF 处理空字符串 ## 文件变更 新增文件: 13 个 修改文件: 14 个 详见文档: docs/自建情景功能完整文档.md
This commit is contained in:
@@ -1972,3 +1972,421 @@ body {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Custom Scenarios Styles
|
||||
============================================================ */
|
||||
|
||||
/* Scenario list */
|
||||
.scenario-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.scenario-item input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-item__icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.scenario-item__name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.scenario-item__desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Custom scenario with edit/delete buttons */
|
||||
.scenario-item--custom {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.scenario-item__radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-item__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.scenario-item__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scenario-action-btn {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.scenario-action-btn--confirm {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
.scenario-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Create scenario button */
|
||||
.config-create-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: rgba(196, 97, 47, 0.15);
|
||||
border: 1px solid rgba(196, 97, 47, 0.3);
|
||||
border-radius: 999px;
|
||||
color: #C4612F;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.config-create-btn:hover {
|
||||
background: rgba(196, 97, 47, 0.25);
|
||||
border-color: rgba(196, 97, 47, 0.5);
|
||||
}
|
||||
|
||||
.config-group__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1F2421;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
.modal--large {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal__close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-required {
|
||||
color: #C4612F;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(196, 97, 47, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
padding: 12px;
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Icon picker */
|
||||
.icon-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.icon-picker__item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-picker__item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.icon-picker__item--active {
|
||||
background: rgba(196, 97, 47, 0.2);
|
||||
border-color: rgba(196, 97, 47, 0.5);
|
||||
}
|
||||
|
||||
/* Prompt guide */
|
||||
.form-guide-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #C4612F;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-guide-btn:hover {
|
||||
background: rgba(196, 97, 47, 0.1);
|
||||
}
|
||||
|
||||
.form-guide {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-guide strong {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.form-guide ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.form-guide__code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: #C4612F;
|
||||
color: white;
|
||||
border-color: #C4612F;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: #A94E22;
|
||||
border-color: #A94E22;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useVisionSession } from "./hooks/useVisionSession";
|
||||
import { useSessionList } from "./hooks/useSessionList";
|
||||
import { useScenarios } from "./hooks/useScenarios";
|
||||
import { VideoPreview } from "./components/VideoPreview";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { SessionSidebar } from "./components/SessionSidebar";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { LandingPage } from "./components/LandingPage";
|
||||
import { CreateScenarioModal } from "./components/CreateScenarioModal";
|
||||
import { EditScenarioModal } from "./components/EditScenarioModal";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||
import { scenarios } from "./lib/scenarios";
|
||||
import type { Locale } from "./lib/i18n";
|
||||
import type { Theme } from "./types";
|
||||
import type { UserScenario } from "./lib/api/scenarios";
|
||||
import "./App.css";
|
||||
|
||||
/** AI 视觉模式 */
|
||||
@@ -28,12 +31,22 @@ type VisionMode = "realtime" | "ondemand" | "chat";
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth();
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showCreateScenario, setShowCreateScenario] = useState(false);
|
||||
const [editingScenario, setEditingScenario] = useState<UserScenario | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ---- 自建情景管理 ----
|
||||
const {
|
||||
allScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
} = useScenarios(accessToken);
|
||||
|
||||
// 切换主题时更新 <html> 的 data-theme 属性
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
@@ -185,15 +198,20 @@ function AppContent() {
|
||||
|
||||
// ---- 情景选择 ----
|
||||
const handleSelectScenario = useCallback((scenarioId: string) => {
|
||||
const sc = scenarios.find(s => s.id === scenarioId);
|
||||
const sc = allScenarios.find((s) => s.id === scenarioId);
|
||||
const updates: Partial<import("./types").SessionConfig> = { scenario: scenarioId };
|
||||
// 如果情景有默认语言,同步切换
|
||||
if (sc?.defaultLanguage) {
|
||||
updates.language = sc.defaultLanguage;
|
||||
// 如果情景有默认语言,同步切换(仅系统预置情景)
|
||||
if (sc && !sc.isCustom) {
|
||||
const systemScenario = sc as any;
|
||||
if (systemScenario.defaultLanguage) {
|
||||
updates.language = systemScenario.defaultLanguage;
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
// 插入系统提示消息
|
||||
const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId;
|
||||
const scenarioName = sc
|
||||
? `${sc.icon} ${sc.nameKey ? tr(sc.nameKey) : sc.name}`
|
||||
: scenarioId;
|
||||
setMessages(prev => [...prev, {
|
||||
id: uuidv4(),
|
||||
role: "system",
|
||||
@@ -203,7 +221,7 @@ function AppContent() {
|
||||
}, [updateConfig, setMessages, tr]);
|
||||
|
||||
// 当前情景对象
|
||||
const activeScenario = scenarios.find(s => s.id === (config.scenario || "free_chat")) || scenarios[0];
|
||||
const activeScenario = allScenarios.find((s) => s.id === (config.scenario || "free_chat")) || allScenarios[0];
|
||||
|
||||
// ---- 键盘快捷键 ----
|
||||
useEffect(() => {
|
||||
@@ -293,10 +311,41 @@ function AppContent() {
|
||||
config={config}
|
||||
theme={theme}
|
||||
username={user?.username}
|
||||
allScenarios={allScenarios}
|
||||
onUpdate={updateConfig}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLogout={logout}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onCreateScenario={() => {
|
||||
setShowConfig(false);
|
||||
setShowCreateScenario(true);
|
||||
}}
|
||||
onEditScenario={(scenario) => {
|
||||
setShowConfig(false);
|
||||
setEditingScenario(scenario);
|
||||
}}
|
||||
onDeleteScenario={deleteScenario}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 创建情景 Modal */}
|
||||
{showCreateScenario && (
|
||||
<CreateScenarioModal
|
||||
onClose={() => setShowCreateScenario(false)}
|
||||
onSubmit={async (data) => {
|
||||
await createScenario(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑情景 Modal */}
|
||||
{editingScenario && (
|
||||
<EditScenarioModal
|
||||
scenario={editingScenario}
|
||||
onClose={() => setEditingScenario(null)}
|
||||
onSubmit={async (id, data) => {
|
||||
await updateScenario(id, data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -492,8 +541,10 @@ function AppContent() {
|
||||
onChange={(e) => handleSelectScenario(e.target.value)}
|
||||
title={tr("settings.scenario")}
|
||||
>
|
||||
{scenarios.map((sc) => (
|
||||
<option key={sc.id} value={sc.id}>{sc.icon} {tr(sc.nameKey)}</option>
|
||||
{allScenarios.map((sc) => (
|
||||
<option key={sc.id} value={sc.id}>
|
||||
{sc.icon} {sc.nameKey ? tr(sc.nameKey) : sc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isConnected && mode === "observation" && (
|
||||
|
||||
@@ -1,31 +1,71 @@
|
||||
// ============================================================
|
||||
// ConfigPanel — 右侧抽屉式配置面板
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择、情景选择(含自建)
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { scenarios } from "../../lib/scenarios";
|
||||
import type { SessionConfig, Theme } from "../../types";
|
||||
import type { ExtendedScenario } from "../../hooks/useScenarios";
|
||||
import type { UserScenario } from "../../lib/api/scenarios";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: SessionConfig;
|
||||
theme: Theme;
|
||||
username?: string;
|
||||
allScenarios: ExtendedScenario[];
|
||||
onUpdate: (partial: Partial<SessionConfig>) => void;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
onLogout?: () => void;
|
||||
onClose: () => void;
|
||||
onCreateScenario: () => void;
|
||||
onEditScenario: (scenario: UserScenario) => void;
|
||||
onDeleteScenario: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) {
|
||||
export function ConfigPanel({
|
||||
config,
|
||||
theme,
|
||||
username,
|
||||
allScenarios,
|
||||
onUpdate,
|
||||
onThemeChange,
|
||||
onLogout,
|
||||
onClose,
|
||||
onCreateScenario,
|
||||
onEditScenario,
|
||||
onDeleteScenario,
|
||||
}: ConfigPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// 分组:系统预置 vs 自建
|
||||
const systemScenarios = allScenarios.filter((s) => !s.isCustom);
|
||||
const customScenarios = allScenarios.filter((s) => s.isCustom);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (deleteConfirm === id) {
|
||||
onDeleteScenario(id);
|
||||
setDeleteConfirm(null);
|
||||
// 如果当前选中的情景被删除,切换回自由对话
|
||||
if (config.scenario === id) {
|
||||
onUpdate({ scenario: "free_chat" });
|
||||
}
|
||||
} else {
|
||||
setDeleteConfirm(id);
|
||||
// 3秒后自动取消确认
|
||||
setTimeout(() => setDeleteConfirm(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drawer-overlay" onClick={onClose}>
|
||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="drawer__header">
|
||||
<span className="drawer__title">{t("settings.title")}</span>
|
||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
||||
<button className="drawer__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer__body">
|
||||
@@ -90,21 +130,89 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">{t("settings.scenario")}</span>
|
||||
<span className="config-row__desc">{t("settings.scenario.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.scenario || "free_chat"}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
{/* 系统预置情景 */}
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">{t("settings.scenario.system")}</div>
|
||||
<div className="scenario-list">
|
||||
{systemScenarios.map((sc) => (
|
||||
<label key={sc.id} className="scenario-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="scenario"
|
||||
value={sc.id}
|
||||
checked={config.scenario === sc.id}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
/>
|
||||
<span className="scenario-item__icon">{sc.icon}</span>
|
||||
<span className="scenario-item__name">
|
||||
{sc.nameKey ? t(sc.nameKey) : sc.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自建情景 */}
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">
|
||||
{t("settings.scenario.custom")}
|
||||
<button
|
||||
className="config-create-btn"
|
||||
onClick={onCreateScenario}
|
||||
title={t("scenario.create.button")}
|
||||
>
|
||||
{scenarios.map((sc) => (
|
||||
<option key={sc.id} value={sc.id}>{sc.icon} {t(sc.nameKey)}</option>
|
||||
+ {t("scenario.create.button")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customScenarios.length === 0 ? (
|
||||
<div className="scenario-empty">
|
||||
{t("settings.scenario.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="scenario-list">
|
||||
{customScenarios.map((sc) => (
|
||||
<div key={sc.id} className="scenario-item scenario-item--custom">
|
||||
<label className="scenario-item__radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="scenario"
|
||||
value={sc.id}
|
||||
checked={config.scenario === sc.id}
|
||||
onChange={(e) => onUpdate({ scenario: e.target.value })}
|
||||
/>
|
||||
<span className="scenario-item__icon">{sc.icon}</span>
|
||||
<div className="scenario-item__info">
|
||||
<span className="scenario-item__name">{sc.name}</span>
|
||||
{sc.description && (
|
||||
<span className="scenario-item__desc">{sc.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<div className="scenario-item__actions">
|
||||
<button
|
||||
className="scenario-action-btn scenario-action-btn--edit"
|
||||
onClick={() => onEditScenario(sc as unknown as UserScenario)}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className={`scenario-action-btn scenario-action-btn--delete ${
|
||||
deleteConfirm === sc.id ? "scenario-action-btn--confirm" : ""
|
||||
}`}
|
||||
onClick={() => handleDelete(sc.id)}
|
||||
title={deleteConfirm === sc.id ? t("common.confirmDelete") : t("common.delete")}
|
||||
>
|
||||
{deleteConfirm === sc.id ? "✓" : "🗑"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{username && onLogout && (
|
||||
@@ -116,10 +224,7 @@ export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange,
|
||||
<span className="config-row__desc">{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="config-logout-btn"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<button className="config-logout-btn" onClick={onLogout}>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
263
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
263
frontend/src/components/CreateScenarioModal/index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
// ============================================================
|
||||
// CreateScenarioModal — 创建自建情景对话框
|
||||
// 职责:提供表单让用户创建新的自定义情景
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { CreateScenarioRequest } from "../../lib/api/scenarios";
|
||||
|
||||
interface CreateScenarioModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateScenarioRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
// 预设常用图标
|
||||
const PRESET_ICONS = [
|
||||
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
||||
"📖", "📚", "📝", "📋", "📌", "📍",
|
||||
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
||||
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
||||
];
|
||||
|
||||
export function CreateScenarioModal({ onClose, onSubmit }: CreateScenarioModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState("✨");
|
||||
const [description, setDescription] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [language, setLanguage] = useState("zh-CN");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 表单验证
|
||||
if (name.length < 2 || name.length > 50) {
|
||||
setError(t("scenario.error.nameLength"));
|
||||
return;
|
||||
}
|
||||
if (prompt.length < 10 || prompt.length > 2000) {
|
||||
setError(t("scenario.error.promptLength"));
|
||||
return;
|
||||
}
|
||||
if (greeting && greeting.length > 500) {
|
||||
setError(t("scenario.error.greetingLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name,
|
||||
icon,
|
||||
description: description || undefined,
|
||||
prompt,
|
||||
greeting: greeting || undefined,
|
||||
language,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<span className="modal__title">{t("scenario.create.title")}</span>
|
||||
<button className="modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="modal__body" onSubmit={handleSubmit}>
|
||||
{/* 名称 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("scenario.create.namePlaceholder")}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{name.length}/50</span>
|
||||
</div>
|
||||
|
||||
{/* 图标选择 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||
<div className="icon-picker">
|
||||
{PRESET_ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
||||
onClick={() => setIcon(ic)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.description")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="form-hint">{description.length}/100</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||
<button
|
||||
type="button"
|
||||
className="form-guide-btn"
|
||||
onClick={() => setShowGuide(!showGuide)}
|
||||
>
|
||||
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||
</button>
|
||||
</label>
|
||||
{showGuide && (
|
||||
<div className="form-guide">
|
||||
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||
<ul>
|
||||
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||
</ul>
|
||||
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||
<pre className="form-guide__code">
|
||||
{`你是一位创意写作导师。
|
||||
帮助用户构思故事情节、人物设定和写作技巧。
|
||||
|
||||
【角色定位】
|
||||
- 你是导师,不是代笔人
|
||||
- 激发用户创意,不直接给答案
|
||||
|
||||
【交互规则】
|
||||
1. 提出启发性问题
|
||||
2. 给出具体、可操作的建议
|
||||
3. 回答控制在3-5句话`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t("scenario.create.promptPlaceholder")}
|
||||
rows={8}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{prompt.length}/2000</span>
|
||||
</div>
|
||||
|
||||
{/* 首句引导 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<span className="form-hint">{greeting.length}/500</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.language")}</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("common.creating") : t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
frontend/src/components/EditScenarioModal/index.tsx
Normal file
264
frontend/src/components/EditScenarioModal/index.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
// ============================================================
|
||||
// EditScenarioModal — 编辑自建情景对话框
|
||||
// 职责:提供表单让用户编辑现有的自定义情景
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { UpdateScenarioRequest, UserScenario } from "../../lib/api/scenarios";
|
||||
|
||||
interface EditScenarioModalProps {
|
||||
scenario: UserScenario;
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, data: UpdateScenarioRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
// 预设常用图标
|
||||
const PRESET_ICONS = [
|
||||
"✨", "🎭", "🎨", "🎯", "🎪", "🎬",
|
||||
"📖", "📚", "📝", "📋", "📌", "📍",
|
||||
"🔬", "🔭", "🔮", "💡", "💼", "💻",
|
||||
"🎓", "🎤", "🎵", "🎸", "🎹", "🎺",
|
||||
];
|
||||
|
||||
export function EditScenarioModal({ scenario, onClose, onSubmit }: EditScenarioModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState(scenario.name);
|
||||
const [icon, setIcon] = useState(scenario.icon);
|
||||
const [description, setDescription] = useState(scenario.description);
|
||||
const [prompt, setPrompt] = useState(scenario.prompt);
|
||||
const [greeting, setGreeting] = useState(scenario.greeting);
|
||||
const [language, setLanguage] = useState(scenario.language);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// 表单验证
|
||||
if (name.length < 2 || name.length > 50) {
|
||||
setError(t("scenario.error.nameLength"));
|
||||
return;
|
||||
}
|
||||
if (prompt.length < 10 || prompt.length > 2000) {
|
||||
setError(t("scenario.error.promptLength"));
|
||||
return;
|
||||
}
|
||||
if (greeting && greeting.length > 500) {
|
||||
setError(t("scenario.error.greetingLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(scenario.id, {
|
||||
name,
|
||||
icon,
|
||||
description: description || undefined,
|
||||
prompt,
|
||||
greeting: greeting || undefined,
|
||||
language,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[scenario.id, name, icon, description, prompt, greeting, language, onSubmit, onClose, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className="modal modal--large" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<span className="modal__title">{t("scenario.edit.title")}</span>
|
||||
<button className="modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="modal__body" onSubmit={handleSubmit}>
|
||||
{/* 名称 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.name")} <span className="form-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("scenario.create.namePlaceholder")}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{name.length}/50</span>
|
||||
</div>
|
||||
|
||||
{/* 图标选择 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.icon")}</label>
|
||||
<div className="icon-picker">
|
||||
{PRESET_ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
className={`icon-picker__item ${icon === ic ? "icon-picker__item--active" : ""}`}
|
||||
onClick={() => setIcon(ic)}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.description")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("scenario.create.descriptionPlaceholder")}
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="form-hint">{description.length}/100</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
{t("scenario.create.prompt")} <span className="form-required">*</span>
|
||||
<button
|
||||
type="button"
|
||||
className="form-guide-btn"
|
||||
onClick={() => setShowGuide(!showGuide)}
|
||||
>
|
||||
{showGuide ? "▼" : "▶"} {t("scenario.create.promptGuide")}
|
||||
</button>
|
||||
</label>
|
||||
{showGuide && (
|
||||
<div className="form-guide">
|
||||
<p><strong>{t("scenario.create.promptGuide.tips")}</strong></p>
|
||||
<ul>
|
||||
<li>{t("scenario.create.promptGuide.tip1")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip2")}</li>
|
||||
<li>{t("scenario.create.promptGuide.tip3")}</li>
|
||||
</ul>
|
||||
<p><strong>{t("scenario.create.promptGuide.example")}</strong></p>
|
||||
<pre className="form-guide__code">
|
||||
{`你是一位创意写作导师。
|
||||
帮助用户构思故事情节、人物设定和写作技巧。
|
||||
|
||||
【角色定位】
|
||||
- 你是导师,不是代笔人
|
||||
- 激发用户创意,不直接给答案
|
||||
|
||||
【交互规则】
|
||||
1. 提出启发性问题
|
||||
2. 给出具体、可操作的建议
|
||||
3. 回答控制在3-5句话`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={t("scenario.create.promptPlaceholder")}
|
||||
rows={8}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">{prompt.length}/2000</span>
|
||||
</div>
|
||||
|
||||
{/* 首句引导 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.greeting")}</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
placeholder={t("scenario.create.greetingPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<span className="form-hint">{greeting.length}/500</span>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t("scenario.create.language")}</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("common.saving") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/hooks/useScenarios.ts
Normal file
147
frontend/src/hooks/useScenarios.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// ============================================================
|
||||
// useScenarios — 用户自建情景管理 Hook
|
||||
// 职责:封装自建情景的加载、创建、更新、删除逻辑,合并系统预置情景
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { scenarios as systemScenarios } from "../lib/scenarios";
|
||||
import * as api from "../lib/api/scenarios";
|
||||
|
||||
export interface ExtendedScenario {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string; // 直接显示的名称(系统情景用 nameKey,自建情景用 name)
|
||||
nameKey?: string; // i18n key(仅系统预置情景有)
|
||||
descKey?: string; // i18n key(仅系统预置情景有)
|
||||
description?: string; // 直接显示的描述(自建情景)
|
||||
isCustom: boolean; // true = 自建情景,false = 系统预置
|
||||
prompt?: string; // 仅自建情景有
|
||||
greeting?: string; // 仅自建情景有
|
||||
language?: string; // 仅自建情景有
|
||||
}
|
||||
|
||||
export function useScenarios(token: string | null) {
|
||||
const [customScenarios, setCustomScenarios] = useState<api.UserScenario[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 合并系统预置 + 自建情景
|
||||
const allScenarios: ExtendedScenario[] = [
|
||||
// 系统预置情景
|
||||
...systemScenarios.map((s) => ({
|
||||
id: s.id,
|
||||
icon: s.icon,
|
||||
name: s.nameKey,
|
||||
nameKey: s.nameKey,
|
||||
descKey: s.descKey,
|
||||
isCustom: false,
|
||||
})),
|
||||
// 用户自建情景
|
||||
...customScenarios.map((s: api.UserScenario) => ({
|
||||
id: s.id,
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
isCustom: true,
|
||||
prompt: s.prompt,
|
||||
greeting: s.greeting,
|
||||
language: s.language,
|
||||
})),
|
||||
];
|
||||
|
||||
// 加载用户自建情景
|
||||
const loadCustomScenarios = useCallback(async () => {
|
||||
if (!token) {
|
||||
setCustomScenarios([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.listUserScenarios(token);
|
||||
setCustomScenarios(result.scenarios || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load custom scenarios:", err);
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// 创建新情景
|
||||
const createScenario = useCallback(
|
||||
async (data: api.CreateScenarioRequest) => {
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const newScenario = await api.createUserScenario(token, data);
|
||||
setCustomScenarios((prev) => [...prev, newScenario]);
|
||||
return newScenario;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
// 更新情景
|
||||
const updateScenario = useCallback(
|
||||
async (id: string, data: api.UpdateScenarioRequest) => {
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateUserScenario(token, id, data);
|
||||
setCustomScenarios((prev) =>
|
||||
prev.map((s) => (s.id === id ? updated : s))
|
||||
);
|
||||
return updated;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
// 删除情景
|
||||
const deleteScenario = useCallback(
|
||||
async (id: string) => {
|
||||
if (!token) throw new Error("Not authenticated");
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteUserScenario(token, id);
|
||||
setCustomScenarios((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
// 登录后自动加载
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadCustomScenarios();
|
||||
}
|
||||
}, [token, loadCustomScenarios]);
|
||||
|
||||
return {
|
||||
allScenarios,
|
||||
customScenarios,
|
||||
isLoading,
|
||||
error,
|
||||
loadCustomScenarios,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
};
|
||||
}
|
||||
120
frontend/src/lib/api/scenarios.ts
Normal file
120
frontend/src/lib/api/scenarios.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// ============================================================
|
||||
// scenarios API — 用户自建情景 API 调用
|
||||
// 职责:封装 /api/scenarios 的 CRUD 操作
|
||||
// ============================================================
|
||||
|
||||
// 开发环境通过 Vite 代理,生产环境使用同域名
|
||||
const API_BASE = "";
|
||||
|
||||
export interface UserScenario {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
greeting: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScenarioRequest {
|
||||
name: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
greeting?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScenarioRequest {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
greeting?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface ScenariosListResponse {
|
||||
scenarios: UserScenario[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 获取用户的所有自建情景
|
||||
export async function listUserScenarios(token: string): Promise<ScenariosListResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/scenarios`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||
throw new Error(err.error || "Failed to list scenarios");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 创建新情景
|
||||
export async function createUserScenario(
|
||||
token: string,
|
||||
data: CreateScenarioRequest
|
||||
): Promise<UserScenario> {
|
||||
const res = await fetch(`${API_BASE}/api/scenarios`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||
throw new Error(err.error || "Failed to create scenario");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 获取单个情景详情
|
||||
export async function getUserScenario(token: string, id: string): Promise<UserScenario> {
|
||||
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||
throw new Error(err.error || "Failed to get scenario");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 更新情景
|
||||
export async function updateUserScenario(
|
||||
token: string,
|
||||
id: string,
|
||||
data: UpdateScenarioRequest
|
||||
): Promise<UserScenario> {
|
||||
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||
throw new Error(err.error || "Failed to update scenario");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 删除情景
|
||||
export async function deleteUserScenario(token: string, id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/scenarios/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Network error" }));
|
||||
throw new Error(err.error || "Failed to delete scenario");
|
||||
}
|
||||
}
|
||||
@@ -178,4 +178,41 @@ export const enUS: TranslationMap = {
|
||||
// Account (in settings)
|
||||
"settings.account": "Account",
|
||||
"settings.account.user": "Current user",
|
||||
|
||||
// Custom scenarios
|
||||
"settings.scenario.system": "System Scenarios",
|
||||
"settings.scenario.custom": "My Scenarios",
|
||||
"settings.scenario.empty": "No custom scenarios yet. Click the button above to create one.",
|
||||
"scenario.create.button": "New Scenario",
|
||||
"scenario.create.title": "Create Custom Scenario",
|
||||
"scenario.edit.title": "Edit Scenario",
|
||||
"scenario.create.name": "Scenario Name",
|
||||
"scenario.create.namePlaceholder": "e.g. Creative Writing Coach",
|
||||
"scenario.create.icon": "Icon",
|
||||
"scenario.create.description": "Brief Description",
|
||||
"scenario.create.descriptionPlaceholder": "One-line summary of this scenario",
|
||||
"scenario.create.prompt": "System Prompt",
|
||||
"scenario.create.promptPlaceholder": "Define the AI's role and interaction rules...",
|
||||
"scenario.create.promptGuide": "View Guide",
|
||||
"scenario.create.promptGuide.tips": "Writing tips:",
|
||||
"scenario.create.promptGuide.tip1": "Clearly define the role: who you are, who you are not",
|
||||
"scenario.create.promptGuide.tip2": "List interaction rules: how to respond, how many sentences",
|
||||
"scenario.create.promptGuide.tip3": "Add constraints: what not to do",
|
||||
"scenario.create.promptGuide.example": "Example:",
|
||||
"scenario.create.greeting": "Greeting (Optional)",
|
||||
"scenario.create.greetingPlaceholder": "The first message when switching to this scenario...",
|
||||
"scenario.create.language": "Default Language",
|
||||
"scenario.error.nameLength": "Scenario name must be 2-50 characters",
|
||||
"scenario.error.promptLength": "Prompt must be 10-2000 characters",
|
||||
"scenario.error.greetingLength": "Greeting must not exceed 500 characters",
|
||||
|
||||
// Common actions
|
||||
"common.cancel": "Cancel",
|
||||
"common.create": "Create",
|
||||
"common.creating": "Creating...",
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving...",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.confirmDelete": "Click again to confirm",
|
||||
};
|
||||
|
||||
@@ -178,4 +178,41 @@ export const jaJP: TranslationMap = {
|
||||
// Account (in settings)
|
||||
"settings.account": "アカウント",
|
||||
"settings.account.user": "現在のユーザー",
|
||||
|
||||
// Custom scenarios
|
||||
"settings.scenario.system": "システムシナリオ",
|
||||
"settings.scenario.custom": "マイシナリオ",
|
||||
"settings.scenario.empty": "カスタムシナリオはまだありません。上のボタンをクリックして作成してください。",
|
||||
"scenario.create.button": "新しいシナリオ",
|
||||
"scenario.create.title": "カスタムシナリオを作成",
|
||||
"scenario.edit.title": "シナリオを編集",
|
||||
"scenario.create.name": "シナリオ名",
|
||||
"scenario.create.namePlaceholder": "例:クリエイティブライティングコーチ",
|
||||
"scenario.create.icon": "アイコン",
|
||||
"scenario.create.description": "簡単な説明",
|
||||
"scenario.create.descriptionPlaceholder": "このシナリオの概要を一文で",
|
||||
"scenario.create.prompt": "システムプロンプト",
|
||||
"scenario.create.promptPlaceholder": "AIの役割と対話ルールを定義...",
|
||||
"scenario.create.promptGuide": "ガイドを見る",
|
||||
"scenario.create.promptGuide.tips": "作成のヒント:",
|
||||
"scenario.create.promptGuide.tip1": "役割を明確に定義:何者で、何者でないか",
|
||||
"scenario.create.promptGuide.tip2": "対話ルールをリスト化:応答方法、文数",
|
||||
"scenario.create.promptGuide.tip3": "制約を追加:何をしないか",
|
||||
"scenario.create.promptGuide.example": "例:",
|
||||
"scenario.create.greeting": "挨拶(オプション)",
|
||||
"scenario.create.greetingPlaceholder": "このシナリオに切り替えた時の最初のメッセージ...",
|
||||
"scenario.create.language": "デフォルト言語",
|
||||
"scenario.error.nameLength": "シナリオ名は2〜50文字で入力してください",
|
||||
"scenario.error.promptLength": "プロンプトは10〜2000文字で入力してください",
|
||||
"scenario.error.greetingLength": "挨拶は500文字以内で入力してください",
|
||||
|
||||
// Common actions
|
||||
"common.cancel": "キャンセル",
|
||||
"common.create": "作成",
|
||||
"common.creating": "作成中...",
|
||||
"common.save": "保存",
|
||||
"common.saving": "保存中...",
|
||||
"common.edit": "編集",
|
||||
"common.delete": "削除",
|
||||
"common.confirmDelete": "もう一度クリックして確認",
|
||||
};
|
||||
|
||||
@@ -178,4 +178,41 @@ export const zhCN: TranslationMap = {
|
||||
// Account (in settings)
|
||||
"settings.account": "账号",
|
||||
"settings.account.user": "当前用户",
|
||||
|
||||
// Custom scenarios
|
||||
"settings.scenario.system": "系统预置情景",
|
||||
"settings.scenario.custom": "我的情景",
|
||||
"settings.scenario.empty": "还没有自建情景,点击上方按钮创建",
|
||||
"scenario.create.button": "创建新情景",
|
||||
"scenario.create.title": "创建自建情景",
|
||||
"scenario.edit.title": "编辑情景",
|
||||
"scenario.create.name": "情景名称",
|
||||
"scenario.create.namePlaceholder": "例如:创意写作导师",
|
||||
"scenario.create.icon": "图标",
|
||||
"scenario.create.description": "简短描述",
|
||||
"scenario.create.descriptionPlaceholder": "一句话介绍这个情景的作用",
|
||||
"scenario.create.prompt": "System Prompt",
|
||||
"scenario.create.promptPlaceholder": "定义 AI 的角色和交互规则...",
|
||||
"scenario.create.promptGuide": "查看编写指南",
|
||||
"scenario.create.promptGuide.tips": "编写提示:",
|
||||
"scenario.create.promptGuide.tip1": "明确角色定位:你是谁,不是谁",
|
||||
"scenario.create.promptGuide.tip2": "列出交互规则:如何回答,每次几句话",
|
||||
"scenario.create.promptGuide.tip3": "添加约束条件:不要做什么",
|
||||
"scenario.create.promptGuide.example": "示例:",
|
||||
"scenario.create.greeting": "首句引导(可选)",
|
||||
"scenario.create.greetingPlaceholder": "切换到此情景时,AI 的第一句话...",
|
||||
"scenario.create.language": "默认语言",
|
||||
"scenario.error.nameLength": "情景名称需要 2-50 个字符",
|
||||
"scenario.error.promptLength": "Prompt 需要 10-2000 个字符",
|
||||
"scenario.error.greetingLength": "首句引导不超过 500 个字符",
|
||||
|
||||
// Common actions
|
||||
"common.cancel": "取消",
|
||||
"common.create": "创建",
|
||||
"common.creating": "创建中...",
|
||||
"common.save": "保存",
|
||||
"common.saving": "保存中...",
|
||||
"common.edit": "编辑",
|
||||
"common.delete": "删除",
|
||||
"common.confirmDelete": "再次点击确认删除",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user