Compare commits

..
2 Commits
Author SHA1 Message Date
mp_1787300862_s8r25x 4175880d87 chore: 忽略本地推送脚本 2026-09-15 16:08:01 +08:00
admin 5fdf5797a8 init: 旅行商品发布AI助手Demo
- 单文件交互式Demo:六大表单模块 + 右侧AI助手面板
- 一键发品(弹窗式)与对话式自主发品(Agent模式)
- 多工具串行调度 + 逐工具确认(human-in-the-loop)
- 偏好学习机制(localStorage,越用越好用)
- Prompt提示词设计文档与项目方法论笔记
2026-08-27 17:27:20 +08:00
32 changed files with 19 additions and 4179 deletions
-3
View File
@@ -14,8 +14,5 @@
# 本地服务
node_modules/
# 构建产物
dist/
# 本地推送脚本(含本地环境信息,不入库)
push-to-gitea.sh
+19 -69
View File
@@ -2,79 +2,19 @@
飞猪商家商品发布平台右侧嵌入的 AI 助手交互式演示。模拟完整的商品发布页(六大模块表单)+ 右侧 AI 助手面板,主打「一键发品」与「越用越好用」的偏好学习机制。
仓库包含两个版本,展示同一个 Demo 从「单文件原型」到「前端工程化」的演进:
## 快速开始
| 版本 | 入口 | 说明 |
|---|---|---|
| v1 单文件原型 | `index.html` | HTML + CSS + JS 零依赖,浏览器直接打开 |
| v2 工程化版 | `app/`(Vite + React) | 组件化拆分、状态收敛、可扩展接入真实 LLM |
两个版本的功能与视觉完全一致。
## 快速开始(Vite + React 版)
无需构建,直接用浏览器打开:
```bash
npm install
npm run dev # 开发:http://localhost:5173
npm run build # 构建产物输出到 dist/
npm run preview # 本地预览构建产物
open index.html
```
单文件版无需构建:直接 `open index.html`。
## 工程结构
或启动本地静态服务:
```bash
npx serve .
```
├── index.html # v1 单文件 Demo(保留作对照,不参与 Vite 构建)
├── vite.config.js # Vite 配置(root 指向 app/,与根目录 index.html 互不干扰)
├── app/
│ ├── index.html # Vite 入口
│ └── src/
│ ├── main.jsx # 应用挂载
│ ├── App.jsx # 布局组合 + Store 注入
│ ├── styles/global.css # 全局样式(自 v1 完整迁移,类名不变)
│ ├── data/
│ │ └── genData.js # 8 个工具的结构化生成器(文案/卡片/回填数据)
│ ├── lib/
│ │ ├── agent.js # Agent 意图解析:parseBrief / parseMultiTools / 意图正则
│ │ └── oc.js # 偏好学习:localStorage 持久化 + 成长等级
│ ├── store/
│ │ ├── useAppStore.js # 状态中枢:表单/消息/串行确认/一键发品 + 异步编排流
│ │ └── context.js # Store Context
│ └── components/
│ ├── TopBar.jsx # 顶部导航
│ ├── FormArea.jsx # 左侧表单(6 大模块)
│ ├── cards/ # 6 个表单模块卡片(Title/Intro/Detail/Image/Itinerary/Price)
│ ├── AssistantPanel.jsx # 右侧 AI 助手面板
│ ├── MessageItem.jsx / GenCardView.jsx / TaskCardView.jsx # 消息流三种消息
│ ├── OneClickModal.jsx # 一键发品弹窗(配置→进度→完成 状态机)
│ └── Toast.jsx
└── docs/ # 设计文档
├── CALM设计对照.md # 架构来源与设计取舍(Rasa CALM 范式逐层对照)
├── Prompt提示词设计文档.md # 各工具背后的 Prompt 设计规范(可直接落地真实 LLM)
└── 追问.md # 方法论笔记(AI 价值 / 边界 / 验证框架)
```
### 设计来源:Rasa CALM 范式
本项目的对话编排架构参考 **Rasa CALM**(Conversational AI with Language Models)的四步架构:
```
① LLM 生成 Commands → ② 校验并更新对话状态 → ③ Conversation Repair → ④ 规则引擎确定性执行
```
关键判断是**只有第 ① 步用模型,「理解」之外全是确定逻辑** —— 由此获得可解释、可中断、可兜底的特性,满足商家侧对内容落库前人工复核的要求。
对应到代码:`lib/agent.js` = 理解层(等价 `SetSlot` / `StartFlow`),`data/genData.js` = 流程定义层(等价 `flows.yml`),`store/useAppStore.js` 的四条编排流 = 执行层(等价规则引擎)。
完整的逐层对照、采纳与简化、以及与 CALM 的差异清单,见 [`docs/CALM设计对照.md`](docs/CALM设计对照.md)。
### 架构要点
- **数据驱动渲染**:v1 中拼接 HTML 字符串的生成卡片,v2 重构为结构化数据(`head / lines / foot / tips`),按钮全部是真实 React 事件
- **异步编排与 UI 解耦**:`quickAction / agentRun / multiToolRun / startOC` 四条编排流集中在 store,通过 ref 读取最新状态避免闭包过期
- **human-in-the-loop 串行确认**:每个工具生成后 Promise 挂起,等待自动倒计时(5s)或手动确认,支持运行中随时切换模式
- **偏好学习持久化**:`ocStore` 写入 localStorage,跨会话预填配置与成长等级
## 功能概览
@@ -106,6 +46,14 @@ npm run preview # 本地预览构建产物
- 即时记忆:每次发品沉淀偏好(localStorage),下次自动预填配置
- 成长等级:Lv.1 通用模板(0-2 次)→ Lv.2 风格匹配(3-9 次)→ Lv.3 爆款复刻(10+ 次)
## 目录结构
```
├── index.html # 单文件 Demo(HTML + CSS + JS,零依赖)
├── Prompt提示词设计文档.md # 各工具背后的 Prompt 设计规范(可直接落地真实 LLM)
└── AI领教营/ # 项目方法论笔记(AI 价值/边界/验证框架)
```
## 设计规范
- **品牌主色**:`#FF7A1A`(飞猪橙黄),渐变 `#FFA200 → #FF6600`
@@ -113,9 +61,11 @@ npm run preview # 本地预览构建产物
## 落地指引
Demo 中的生成逻辑为前端模拟数据(`app/src/data/genData.js`)。接入真实 LLM 时:
Demo 中的生成逻辑为前端模拟数据(`genData`)。接入真实 LLM 时:
1. 将 `lib/agent.js` 的意图解析替换为 LLM function calling
2. 按 `Prompt提示词设计文档.md` 定义 API 契约(表单上下文 + 生成类型 → 流式返回)
1. 将助手面板抽成 Vue/React 独立组件
2. 按设计文档定义 API 契约(表单上下文 + 生成类型 → 流式返回)
3. Agent 编排对应后端 Agent Loop:意图分类 → 参数抽取 → function calling → 结果回填 → 偏好沉淀
4. 偏好沉淀对应商家画像服务,喂给 LLM 的个性化 prompt
详见 `Prompt提示词设计文档.md`。
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>商品发布 - 旅智工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
import { useAppStore } from './store/useAppStore'
import { StoreContext } from './store/context'
import TopBar from './components/TopBar'
import FormArea from './components/FormArea'
import AssistantPanel from './components/AssistantPanel'
import OneClickModal from './components/OneClickModal'
import Toast from './components/Toast'
export default function App() {
const store = useAppStore()
return (
<StoreContext.Provider value={store}>
<TopBar />
<div className="main">
<FormArea />
<AssistantPanel />
</div>
<OneClickModal />
<Toast />
</StoreContext.Provider>
)
}
-97
View File
@@ -1,97 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../store/context'
import MessageItem from './MessageItem'
/** 右侧:AI 助手面板(快捷生成 / 消息流 / 对话输入) */
export default function AssistantPanel() {
const {
messages, panelCollapsed, setPanelCollapsed,
quickAction, openOC, sendMessage
} = useStore()
const [input, setInput] = useState('')
const msgListRef = useRef(null)
/* 新消息时自动滚动到底部 */
useEffect(() => {
requestAnimationFrame(() => {
const el = msgListRef.current
if (el) el.scrollTop = el.scrollHeight
})
}, [messages])
const quickItems = [
{ type: 'title', icon: 'q1', em: '📝', label: '商品标题' },
{ type: 'intro', icon: 'q2', em: '💎', label: '详细介绍' },
{ type: 'detail', icon: 'q7', em: '📄', label: '商品详情' },
{ type: 'image', icon: 'q5', em: '🖼️', label: '配图建议' },
{ type: 'itinerary', icon: 'q3', em: '🗺️', label: '行程规划' },
{ type: 'price', icon: 'q4', em: '💰', label: '价格库存' },
{ type: 'seo', icon: 'q6', em: '🔍', label: 'SEO 词' },
{ type: 'check', icon: 'q6', em: '🛡️', label: '合规检查' }
]
const send = () => {
const text = input.trim()
if (!text) return
setInput('')
sendMessage(text)
}
return (
<div className={'ai-panel' + (panelCollapsed ? ' collapsed' : '')}>
<button className="ai-float" onClick={() => setPanelCollapsed(!panelCollapsed)}>AI 助手</button>
<div className="ai-header">
<div className="ai-avatar">🤖</div>
<div className="info">
<div className="name">旅智小助 <span className="badge">商品发布专精</span></div>
<div className="status">在线 · 已读取当前页面表单内容</div>
</div>
<div className="actions">
<button className="icon-btn" title="收起面板" onClick={() => setPanelCollapsed(!panelCollapsed)}>»</button>
</div>
</div>
<div className="ai-quick">
<div className="title">⚡ 快捷生成(对应左侧 6 大模块)</div>
<button className="oc-wide" onClick={openOC}>⚡ 一键发品 · 30 秒完成 6 大模块</button>
<div className="quick-grid">
{quickItems.map(q => (
<button key={q.type} className="quick-btn" onClick={() => quickAction(q.type)}>
<span className={'qicon ' + q.icon}>{q.em}</span>
<span className="qtext">{q.label}</span>
</button>
))}
</div>
</div>
<div className="ai-messages" ref={msgListRef}>
{messages.map(m => <MessageItem key={m.id} msg={m} />)}
</div>
<div className="ai-input-area">
<div className="input-wrap">
<textarea
rows={1}
placeholder="例如:帮我优化标题和详情 / 帮我发个海南亲子6天自由行"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<div className="input-foot">
<div className="input-tools">
<button className="tool-btn" title="参考示例">💡</button>
<button className="tool-btn" title="上传图片">🖼️</button>
</div>
<button className="send-btn" onClick={send}>发送</button>
</div>
</div>
<div className="input-hint">Enter 发送 · Shift+Enter 换行 · 内容由 AI 生成,请核对后发布</div>
</div>
</div>
)
}
-35
View File
@@ -1,35 +0,0 @@
import { useStore } from '../store/context'
import TitleCard from './cards/TitleCard'
import IntroCard from './cards/IntroCard'
import DetailCard from './cards/DetailCard'
import ImageCard from './cards/ImageCard'
import ItineraryCard from './cards/ItineraryCard'
import PriceCard from './cards/PriceCard'
/** 左侧:商品发布表单(6 大模块卡片) */
export default function FormArea() {
const { showToast, openOC, sectionRefs, formAreaRef } = useStore()
const setRef = id => el => { sectionRefs.current[id] = el }
return (
<div className="form-area" ref={formAreaRef}>
<div className="page-head">
<div>
<h1>发布新商品</h1>
<div className="sub">完善商品信息,或用「一键发品」30 秒搞定 · 右侧 AI 助手随时待命 ↗</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn-ghost-w" onClick={() => showToast('已提交审核(演示)')}>提交审核</button>
<button className="btn-magic" onClick={openOC}>⚡ 一键发品</button>
</div>
</div>
{/* 每张卡片把根节点注册到 sectionRefs(AI 填入后高亮 + 滚动定位) */}
<TitleCard setRef={setRef} />
<IntroCard setRef={setRef} />
<DetailCard setRef={setRef} />
<ImageCard setRef={setRef} />
<ItineraryCard setRef={setRef} />
<PriceCard setRef={setRef} />
</div>
)
}
-63
View File
@@ -1,63 +0,0 @@
import { useStore } from '../store/context'
/** 生成卡片:head / lines / foot / tips,foot 按钮为真实 React 事件 */
export default function GenCardView({ gen, confirm }) {
const { applyGen, regen, showToast, serialConfirm, serialRegen, countdown } = useStore()
const { card } = gen
const serial = !!confirm
const body = (
<div className="gen-card">
<div className="gc-head">{card.head}</div>
<div className="gc-body">
{card.lines.map((l, i) => <span key={i} className="line" dangerouslySetInnerHTML={{ __html: l }} />)}
</div>
{card.foot && (
<div className="gc-foot">
<button
className="gc-btn"
onClick={() => card.foot.apply ? applyGen(card.foot.apply) : showToast('SEO 词已复制(演示)')}
>{card.foot.label}</button>
{card.foot.regen && <button className="gc-btn ghost" onClick={() => regen(gen.type)}>🔄 换一版</button>}
</div>
)}
{card.tips.length > 0 && (
<div className="tips-row">
{card.tips.map((t, i) => <span key={i} className={'tip-pill ' + t.cls}>{t.text}</span>)}
</div>
)}
</div>
)
/* 串行流程:外层包 serial-card,底部附确认栏 */
if (serial) {
return (
<div className="serial-card">
<div dangerouslySetInnerHTML={{ __html: gen.pre }} />
{body}
{confirm.status === 'confirmed' ? (
<div className="ag-confirm-bar" style={{ opacity: 0.55 }}>
<span style={{ color: '#22c55e', fontSize: 12, fontWeight: 600 }}>✓ 已确认,填入表单中…</span>
</div>
) : (
<div className="ag-confirm-bar">
<button className="ag-cf-btn" onClick={serialConfirm}>
{card.foot?.apply ? '✅ 确认填入' : '✅ 确认'}
</button>
<button className="ag-regen-btn" onClick={serialRegen}>🔄 换一版</button>
{confirm.mode
? <span className="ag-cd">{countdown}s 后自动确认</span>
: <span className="ag-cd manual">⏸ 等待您确认…</span>}
</div>
)}
</div>
)
}
return (
<div>
<div dangerouslySetInnerHTML={{ __html: gen.pre }} />
{body}
</div>
)
}
-20
View File
@@ -1,20 +0,0 @@
import GenCardView from './GenCardView'
import TaskCardView from './TaskCardView'
/** 单条消息:typing 占位 / 富文本 / 生成卡片 / Agent 任务清单 */
export default function MessageItem({ msg }) {
const role = msg.role
return (
<div className={'msg ' + role}>
<div className="m-avatar">{role === 'ai' ? '🤖' : '李'}</div>
<div className="bubble">
{msg.kind === 'typing' && (
<div className="typing"><span /><span /><span /></div>
)}
{msg.kind === 'html' && <div dangerouslySetInnerHTML={{ __html: msg.html }} />}
{msg.kind === 'gen' && <GenCardView gen={msg.gen} confirm={msg.confirm} />}
{msg.kind === 'task' && <TaskCardView task={msg.task} />}
</div>
</div>
)
}
-118
View File
@@ -1,118 +0,0 @@
import { useStore } from '../store/context'
import { DEST_OPTIONS, DAY_OPTIONS, TYPE_OPTIONS, THEME_OPTIONS } from './cards/TitleCard'
import { ocLevelInfo } from '../lib/oc'
/** 一键发品弹窗:配置 → 生成进度 → 完成(三视图状态机) */
export default function OneClickModal() {
const { ocModal, closeOC, setOCField, startOC, showToast, ocStore } = useStore()
if (!ocModal.open) return null
const lv = ocLevelInfo(ocStore)
const lvPct = ocStore.count >= 10 ? 100 : (ocStore.count / 10) * 100
const stepMeta = [
['商品标题', '已选高转化方案'],
['详细介绍', '四段式卖点'],
['商品详情', '图文结构就绪'],
['商品图片', '配图清单 6 张'],
['行程编辑', '逐日框架就绪'],
['价格库存', '团期+定价就绪']
]
return (
<div className="modal-mask show" onClick={e => { if (e.target === e.currentTarget) closeOC() }}>
<div className="modal">
{/* 视图 1:配置 */}
{ocModal.view === 'config' && (
<div>
<h3>⚡ 一键发品</h3>
<div className="sub">选好基础信息,AI 自动完成 6 大模块填写,30 秒搞定发品</div>
{ocModal.learnVisible && (
<div className="learn-hint" dangerouslySetInnerHTML={{ __html: ocModal.learnHtml }} />
)}
<div className="stat-row">
<div className="stat-box"><div className="v">{ocStore.count}</div><div className="k">累计发品(次)</div></div>
<div className="stat-box"><div className="v">{ocStore.prefs.length}</div><div className="k">已沉淀偏好(条)</div></div>
<div className="stat-box"><div className="v">1<small> 分钟</small></div><div className="k">发品耗时(传统约 30 分钟)</div></div>
</div>
<div className="level-wrap">
<div className="level-text"><span>{lv.name}</span><span>{lv.next}</span></div>
<div className="level-bar"><i style={{ width: lvPct + '%' }} /></div>
</div>
<div className="conf-row">
<div className="form-item">
<label>目的地</label>
<select value={ocModal.dest} onChange={e => setOCField('dest', e.target.value)}>
{DEST_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-item">
<label>天数</label>
<select value={ocModal.days} onChange={e => setOCField('days', e.target.value)}>
{DAY_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-item">
<label>类型</label>
<select value={ocModal.type} onChange={e => setOCField('type', e.target.value)}>
{TYPE_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
</div>
<div className="form-item">
<label>适合人群</label>
<div className="chips">
{THEME_OPTIONS.map(t => (
<span
key={t}
className={'chip' + (ocModal.theme === t ? ' active' : '')}
onClick={() => setOCField('theme', t)}
>{t}</span>
))}
</div>
</div>
<div className="modal-foot">
<button className="btn-ghost-w" onClick={closeOC}>取消</button>
<button className="btn-magic" onClick={startOC}>⚡ 开始生成</button>
</div>
</div>
)}
{/* 视图 2:生成进度 */}
{ocModal.view === 'progress' && (
<div>
<h3>🤖 AI 正在发品…</h3>
<div className="sub">按您的偏好逐个模块生成,完成后可随时接手微调</div>
<div className="step-list">
{stepMeta.map(([label, note], i) => {
const st = ocModal.steps[i]?.status || 'todo'
return (
<div key={i} className={'step-item ' + st}>
<span className="s-icon">{st === 'done' ? '✓' : st === 'doing' ? '↻' : '·'}</span>
<span>{label}</span>
<span className="s-note">{st === 'doing' ? '生成中…' : st === 'done' ? note : '等待中'}</span>
</div>
)
})}
</div>
</div>
)}
{/* 视图 3:完成 */}
{ocModal.view === 'done' && (
<div>
<div className="done-view">
<div className="big">🎉</div>
<h4>发品完成!</h4>
<div className="dsub">6 大模块已全部生成,请核对后提交审核</div>
</div>
<div className="learn-hint" dangerouslySetInnerHTML={{ __html: ocModal.doneHtml }} />
<div className="modal-foot">
<button className="btn-ghost-w" onClick={closeOC}>返回核对</button>
<button className="btn-magic" onClick={() => { closeOC(); showToast('已提交审核(演示)') }}>提交审核</button>
</div>
</div>
)}
</div>
</div>
)
}
-33
View File
@@ -1,33 +0,0 @@
import { useStore } from '../store/context'
/** Agent 任务清单卡片:8 步/多步执行状态实时更新 */
export default function TaskCardView({ task }) {
const { setAutoConfirm, autoConfirmMode } = useStore()
return (
<div className="agent-card">
<div className="ag-head">{task.title}</div>
<div className="ag-ctx">{task.ctxLine}</div>
<div className="ag-mode-row">
<span className="ag-mode-label">确认模式</span>
<button
className={'ag-mt-btn' + (autoConfirmMode ? ' active' : '')}
onClick={() => setAutoConfirm(true)}
>⚡ 自动确认</button>
<button
className={'ag-mt-btn' + (!autoConfirmMode ? ' active' : '')}
onClick={() => setAutoConfirm(false)}
>✋ 手动确认</button>
</div>
<div className="ag-steps">
{task.steps.map((s, i) => (
<div key={i} className={'ag-step ' + s.status}>
<span className="ag-ico">{s.status === 'done' ? '✓' : s.status === 'doing' ? '↻' : '·'}</span>
<span className="ag-lbl">{s.label}</span>
<span className="ag-note">{s.note}</span>
</div>
))}
</div>
<div className="ag-foot">💡 每个工具生成后展示卡片,自动模式 5s 后自动确认填入,手动模式需您点击确认。可随时切换模式。</div>
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react'
import { useStore } from '../store/context'
/** 全局轻提示(1.8s 自动消失,与 v1 行为一致) */
export default function Toast() {
const { toastState } = useStore()
const [visible, setVisible] = useState(false)
useEffect(() => {
if (!toastState) return
setVisible(true)
const t = setTimeout(() => setVisible(false), 1800)
return () => clearTimeout(t)
}, [toastState])
return (
<div className={'toast' + (visible ? ' show' : '')}>
{toastState ? toastState.text : ''}
</div>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { useStore } from '../store/context'
/** 顶部导航:logo / 面包屑 / 草稿状态 */
export default function TopBar() {
const { showToast } = useStore()
return (
<div className="topbar">
<div className="logo"><div className="icon">✈</div> 旅智工作台</div>
<div className="breadcrumb">商品管理 / <b>发布新商品</b></div>
<div className="right">
<span>草稿自动保存于 1 分钟前</span>
<div className="avatar-sm">李</div>
</div>
</div>
)
}
-16
View File
@@ -1,16 +0,0 @@
/** 通用卡片骨架:标题 + AI 内嵌按钮 + 模块序号(refCb 用于 AI 填入后的高亮定位) */
export default function Card({ id, title, aiLabel, aiTitle, onAI, step, refCb, children }) {
return (
<div className="card" id={id} ref={refCb}>
<h2>
<span className="dot" />
{title}
{onAI && (
<button className="ai-inline-btn" title={aiTitle} onClick={onAI}>{aiLabel}</button>
)}
<span className="badge-step">{step}</span>
</h2>
{children}
</div>
)
}
-37
View File
@@ -1,37 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
/** 模块 3/6 —— 商品详情(contenteditable 富文本,AI 可直接写入) */
export default function DetailCard({ setRef }) {
const { formAI, detailRef } = useStore()
return (
<Card
id="card-detail" refCb={setRef('card-detail')} title="商品详情" step="模块 3/6"
aiLabel="✨ AI 生成详情" aiTitle="AI 生成结构化图文详情"
onAI={() => formAI('detail')}
>
<div className="form-item">
<label><span className="req">*</span>详情页图文内容</label>
<div className="rich-editor">
<div className="rich-toolbar">
<button title="加粗">B</button>
<button title="斜体">I</button>
<button title="标题">H</button>
<div className="sep" />
<button title="插入图片">🖼️</button>
<button title="插入视频">🎬</button>
<div className="sep" />
<button title="行程模块">🗺️</button>
<button title="费用说明">¥</button>
</div>
<div
className="rich-content"
ref={el => { detailRef.current = el }}
contentEditable
data-placeholder="支持图文混排,AI 可生成结构化详情内容…"
/>
</div>
</div>
</Card>
)
}
-34
View File
@@ -1,34 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
import { imgPalette } from '../../data/genData'
/** 模块 4/6 —— 商品图片(AI 可按清单批量添加配图占位) */
export default function ImageCard({ setRef }) {
const { images, uploadImg, removeImage, formAI } = useStore()
return (
<Card
id="card-img" refCb={setRef('card-img')} title="商品图片" step="模块 4/6"
aiLabel="✨ AI 配图建议" aiTitle="AI 推荐拍摄脚本与配图清单"
onAI={() => formAI('image')}
>
<div className="form-item">
<label><span className="req">*</span>主图与详情图(首张为主图,建议 800×600 以上)</label>
<div className="img-grid">
{images.map((im, i) => {
const p = imgPalette[i % imgPalette.length]
return (
<div key={i} className="img-tile" style={{ background: p.g }}>
<span className="tag">{i === 0 ? '主图' : '详情图 ' + i}</span>
{im.ai && <span className="ai-tag">AI 建议</span>}
<span className="em">{p.em}</span><span>{im.label}</span>
<button className="del" onClick={() => removeImage(i)}>✕</button>
</div>
)
})}
<div className="img-upload" onClick={uploadImg}><span className="plus">+</span>上传图片</div>
</div>
<div className="hint" style={{ marginTop: 10 }}>AI 可根据行程内容推荐拍摄脚本与配图清单</div>
</div>
</Card>
)
}
-26
View File
@@ -1,26 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
/** 模块 2/6 —— 详细介绍(列表页卖点摘要) */
export default function IntroCard({ setRef }) {
const { form, updateForm, formAI, introHighlight } = useStore()
return (
<Card
id="card-intro" refCb={setRef('card-intro')} title="详细介绍" step="模块 2/6"
aiLabel="✨ AI 生成卖点" aiTitle="AI 生成四段式卖点摘要"
onAI={() => formAI('intro')}
>
<div className="form-item">
<label><span className="req">*</span>详细介绍(列表页展示的卖点摘要)</label>
<textarea
rows={5}
placeholder="突出行程亮点、住宿标准、服务承诺等核心卖点…"
value={form.intro}
className={introHighlight ? 'field-highlight' : ''}
onChange={e => updateForm('intro', e.target.value)}
/>
<div className="hint">建议 150-300 字,聚焦用户最关心的 3-4 个卖点</div>
</div>
</Card>
)
}
@@ -1,40 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
/** 模块 5/6 —— 行程编辑(逐日行程卡片) */
export default function ItineraryCard({ setRef }) {
const { itineraryDays, addDay, removeDay, updateDay, formAI } = useStore()
return (
<Card
id="card-itinerary" refCb={setRef('card-itinerary')} title="行程编辑" step="模块 5/6"
aiLabel="✨ AI 规划行程" aiTitle="AI 按天数生成逐日行程"
onAI={() => formAI('itinerary')}
>
{itineraryDays.length === 0 ? (
<div className="empty-tip">暂无行程,点击下方按钮添加,或让 AI 一键生成 ↗</div>
) : (
itineraryDays.map((d, i) => (
<div key={i} className="day-card">
<div className="day-head">
<span className="dnum">D{i + 1}</span>
<input
value={d.title}
placeholder="当日主题,如:抵达昆明·入住酒店"
onChange={e => updateDay(i, 'title', e.target.value)}
/>
<button className="del-day" title="删除当天" onClick={() => removeDay(i)}>✕</button>
</div>
<div className="day-body">
<textarea
placeholder="当日安排描述…"
value={d.desc}
onChange={e => updateDay(i, 'desc', e.target.value)}
/>
</div>
</div>
))
)}
<button className="add-day" onClick={addDay}>+ 添加一天行程</button>
</Card>
)
}
-56
View File
@@ -1,56 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
/** 模块 6/6 —— 价格库存(团期排期表) */
export default function PriceCard({ setRef }) {
const { priceRows, addPriceRow, removePriceRow, updatePriceRow, togglePriceOn, formAI } = useStore()
return (
<Card
id="card-price" refCb={setRef('card-price')} title="价格库存" step="模块 6/6"
aiLabel="✨ AI 排期定价" aiTitle="AI 生成团期排期与定价方案"
onAI={() => formAI('price')}
>
<table className="price-table">
<thead>
<tr>
<th style={{ width: 150 }}>出发日期</th>
<th>成人价(元)</th>
<th>儿童价(元)</th>
<th style={{ width: 90 }}>库存</th>
<th style={{ width: 80 }}>状态</th>
<th style={{ width: 40 }} />
</tr>
</thead>
<tbody>
{priceRows.length === 0 ? (
<tr><td colSpan={6}><div className="empty-tip">暂无团期,点击下方按钮添加,或让 AI 生成排期方案 ↗</div></td></tr>
) : (
priceRows.map((r, i) => (
<tr key={i}>
<td><input value={r.date} onChange={e => updatePriceRow(i, 'date', e.target.value)} /></td>
<td><input type="number" value={r.adult} onChange={e => updatePriceRow(i, 'adult', e.target.value)} /></td>
<td><input type="number" value={r.child} onChange={e => updatePriceRow(i, 'child', e.target.value)} /></td>
<td>
<input
type="number" value={r.stock}
className={r.stock < 6 ? 'stock-low' : ''}
onChange={e => updatePriceRow(i, 'stock', +e.target.value)}
/>
</td>
<td>
<span
className={'status-pill ' + (r.on ? 'status-on' : 'status-off')}
style={{ cursor: 'pointer' }}
onClick={() => togglePriceOn(i)}
>{r.on ? '在售' : '停售'}</span>
</td>
<td><button className="del-row" onClick={() => removePriceRow(i)}>✕</button></td>
</tr>
))
)}
</tbody>
</table>
<button className="add-day" style={{ marginTop: 10 }} onClick={addPriceRow}>+ 添加团期</button>
</Card>
)
}
-65
View File
@@ -1,65 +0,0 @@
import { useStore } from '../../store/context'
import Card from './Card'
export const DEST_OPTIONS = ['云南(昆明/大理/丽江)', '四川(成都/九寨沟)', '海南(三亚)', '新疆(乌鲁木齐/喀纳斯)', '西藏(拉萨/林芝)']
export const DAY_OPTIONS = ['6天5晚', '3天2晚', '5天4晚', '7天6晚', '8天7晚']
export const TYPE_OPTIONS = ['跟团游', '自由行', '定制游', '半自助游', '当地玩乐']
export const THEME_OPTIONS = ['亲子家庭', '蜜月情侣', '银发无忧', '摄影爱好', '小众深度']
/** 模块 1/6 —— 商品标题(含目的地/天数/类型/人群 上下文) */
export default function TitleCard({ setRef }) {
const { form, updateForm, formAI, titleHighlight } = useStore()
return (
<Card
id="card-title" refCb={setRef('card-title')} title="商品标题" step="模块 1/6"
aiLabel="✨ AI 生成标题" aiTitle="AI 生成 3 个高转化标题"
onAI={() => formAI('title')}
>
<div className="form-row">
<div className="form-item" style={{ flex: 2 }}>
<label><span className="req">*</span>商品标题</label>
<input
type="text" maxLength={40}
placeholder="例:云南昆明+大理+丽江6日5晚纯玩跟团游"
value={form.title}
className={titleHighlight ? 'field-highlight' : ''}
onChange={e => updateForm('title', e.target.value)}
/>
<div className="counter"><span>{form.title.length}</span>/40 字</div>
</div>
<div className="form-item">
<label>目的地</label>
<select value={form.dest} onChange={e => updateForm('dest', e.target.value)}>
{DEST_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-item">
<label>行程天数</label>
<select value={form.days} onChange={e => updateForm('days', e.target.value)}>
{DAY_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-item">
<label>商品类型</label>
<select value={form.type} onChange={e => updateForm('type', e.target.value)}>
{TYPE_OPTIONS.map(o => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-item" style={{ flex: 2 }}>
<label>适合人群</label>
<div className="chips">
{THEME_OPTIONS.map(t => (
<span
key={t}
className={'chip' + (form.theme === t ? ' active' : '')}
onClick={() => updateForm('theme', t)}
>{t}</span>
))}
</div>
</div>
</div>
</Card>
)
}
-245
View File
@@ -1,245 +0,0 @@
/* ============================================================
* 工具(Tool)定义:Agent 可调用的 8 个生成工具
* buildGen(type, ctx) 返回结构化结果:
* pre —— 助手开场白(应用内静态 HTML,非用户输入)
* card —— 生成卡片结构(head / lines / foot / tips),由 GenCardView 渲染
* data —— 填入表单的结构化数据
* ============================================================ */
/** 自主发品 Agent 的 8 步任务清单 */
export const agentSteps = [
{ label: '商品标题', type: 'title', note: '3 方案·已选高转化款填入' },
{ label: '详细介绍', type: 'intro', note: '四段式卖点已填入' },
{ label: '商品详情', type: 'detail', note: '图文结构已写入编辑器' },
{ label: '商品图片', type: 'image', note: '6 张配图清单已添加' },
{ label: '行程编辑', type: 'itinerary', note: '逐日行程已填入' },
{ label: '价格库存', type: 'price', note: '5 个团期 + 定价已填入' },
{ label: 'SEO 关键词', type: 'seo', note: '6 个高流量词已提取' },
{ label: '合规检查', type: 'check', note: '扫描完成·0 极限词风险' }
]
/** 一键发品的 6 步流程 */
export const ocStepsDef = [
{ label: '商品标题', type: 'title', note: '已选高转化方案' },
{ label: '详细介绍', type: 'intro', note: '四段式卖点' },
{ label: '商品详情', type: 'detail', note: '图文结构就绪' },
{ label: '商品图片', type: 'image', note: '配图清单 6 张' },
{ label: '行程编辑', type: 'itinerary', note: '逐日框架就绪' },
{ label: '价格库存', type: 'price', note: '团期+定价就绪' }
]
/** 各工具的动作名称(对话中展示用) */
export const actionLabel = {
title: '生成商品标题', intro: '生成详细介绍', detail: '生成商品详情', image: '配图建议',
itinerary: '规划行程', price: '价格库存方案', seo: 'SEO 关键词', check: '合规检查'
}
/** 图片占位调色板 */
export const imgPalette = [
{ g: 'linear-gradient(135deg,#ffb300,#ff7a1a)', em: '🚤' },
{ g: 'linear-gradient(135deg,#f59e0b,#ef4444)', em: '🏮' },
{ g: 'linear-gradient(135deg,#8b5cf6,#ec4899)', em: '🏔️' },
{ g: 'linear-gradient(135deg,#22c55e,#ffb300)', em: '🏨' },
{ g: 'linear-gradient(135deg,#f97316,#facc15)', em: '🍲' },
{ g: 'linear-gradient(135deg,#6366f1,#ffb300)', em: '🌅' }
]
/** 表单模块 → 卡片锚点(滚动定位用) */
export const cardMap = {
title: 'card-title', intro: 'card-intro', detail: 'card-detail',
image: 'card-img', itinerary: 'card-itinerary', price: 'card-price'
}
/** 生成 8 类工具的结构化结果(ctx 为当前表单上下文) */
export function buildGen(type, ctx) {
const { dest, days, type: pType, theme } = ctx
const isYn = dest === '云南'
switch (type) {
case 'title': {
const t = [
`【纯玩无购物】${dest}${days}精品${pType}·5A景区全含·${theme}首选`,
`${dest}${days}深度${pType}|一价全含0自费·住${isYn ? '海景' : '景区'}酒店`,
`【${theme}必选】${dest}${days}${pType}·随走随停·金牌向导·赠${isYn ? '洱海游船' : '旅拍'}体验`
]
return {
pre: `结合 <span class="hl">${dest}·${days}·${pType}·${theme}</span> 生成 3 个高点击标题(可多次点击填入切换方案):`,
card: {
head: '✨ 标题方案(三选一)',
lines: t.map((x, i) => `<b>${i + 1}.</b> ${x}`),
foot: { apply: 'title', label: '⚡ 一键填入「商品标题」', regen: true },
tips: [
{ cls: 'ok-pill', text: '含目的地+天数+类型,利于搜索' },
{ cls: '', text: '「无购物」为平台高转化词' }
]
},
data: t
}
}
case 'intro': {
const d = `【${dest}${days}${pType}·${theme}优选】全程 0 购物 0 自费,景区门票一价全含,金牌持证导游全程讲解。\n精选 ${days === '3天2晚' ? '2' : '4'} 晚高分酒店,${isYn ? '大理海景房 + 丽江古城客栈' : '近景区步行可达'},住的舒心才是真度假。\n特色体验:${isYn ? '白族扎染 + 洱海游船 + 野生菌火锅' : '深度环线,不赶路'},孩子玩得开心、大人拍得尽兴。\n服务承诺:行程透明无隐形消费,24h 专属管家,未成团提前 3 天全额退款。`
return {
pre: `围绕「${theme}」人群痛点,按「亮点—住宿—体验—承诺」四段式生成详细介绍:`,
card: {
head: '✨ 详细介绍(列表页卖点摘要)',
lines: d.split('\n'),
foot: { apply: 'intro', label: '⚡ 一键填入「详细介绍」', regen: true },
tips: [
{ cls: 'ok-pill', text: '约 200 字,符合最佳展示长度' },
{ cls: '', text: '含服务承诺,降低决策门槛' }
]
},
data: d
}
}
case 'detail': {
return {
pre: '已生成结构化商品详情(亮点/住宿/餐饮/保障 + 配图占位),可直接在编辑器中继续润色:',
card: {
head: '✨ 商品详情(图文结构)',
lines: [
`<b>行程亮点</b> · 0购物0自费 / 经典IP全含 / ${theme}节奏`,
`<b>住宿安排</b> · ${isYn ? '海景房+古城客栈' : '高分酒店'}`,
`<b>舌尖体验</b> · ${isYn ? '野生菌火锅+风味餐' : '特色餐 3 次'}`,
`<b>服务保障</b> · 金牌导游 / 24h管家 / 全额退款承诺`,
`<b>配图占位</b> · 行程地图 / 酒店实拍 / 餐饮实拍`
],
foot: { apply: 'detail', label: '⚡ 写入「商品详情」编辑器', regen: true },
tips: []
},
data: `<h3>🌟 行程亮点</h3>
<ul>
<li>全程 <b>0 购物 0 自费</b>,行程透明,把时间还给风景</li>
<li>${dest}经典 IP 全含:${isYn ? '石林、洱海、玉龙雪山、丽江古城' : '核心景区深度游览'}</li>
<li>${theme}专属节奏:每日行车 ≤ 3.5 小时,不赶路不早起(可睡到自然醒)</li>
</ul>
<h3>🏨 住宿安排</h3>
<ul>
<li>${isYn ? '大理 1 晚海景房 + 丽江 2 晚古城客栈' : '全程高分舒适型酒店'}</li>
<li>提前公布酒店名单,拒绝到店换酒店</li>
</ul>
<h3>🍜 舌尖体验</h3>
<ul>
<li>${isYn ? '野生菌火锅 + 白族风味餐 + 过桥米线' : '当地特色风味餐 3 次'}</li>
</ul>
<h3>🛡️ 服务保障</h3>
<ul>
<li>持证金牌导游 + 24h 专属管家</li>
<li>未成团提前 3 天全额退款,出行无忧</li>
</ul>
<div class="img-slot">🖼️ 此处建议插入:行程地图 / 实拍酒店 / 餐饮实拍(见「配图建议」)</div>`
}
}
case 'image': {
const list = [
{ label: `${dest}全景主图(横版大图)`, use: '主图' },
{ label: '酒店实拍·房间内饰', use: '详情' },
{ label: '核心景区打卡机位', use: '详情' },
{ label: '特色餐食实拍', use: '详情' },
{ label: `${theme}互动瞬间抓拍`, use: '详情' },
{ label: '行程地图示意', use: '详情' }
]
return {
pre: '根据行程内容生成 <b>6 张配图拍摄脚本</b>(含用途与构图建议):',
card: {
head: '✨ 配图清单(转化率优选顺序)',
lines: list.map((im, i) => `<b>${i + 1}.</b> ${im.label} <span style="color:#86909c;font-size:11px">[${im.use}]</span>`),
foot: { apply: 'image', label: '⚡ 按清单添加到「商品图片」', regen: true },
tips: [
{ cls: 'ok-pill', text: '主图含地标,点击率 +18%' },
{ cls: '', text: '实拍图 > 精修图,差评更少' }
]
},
data: list.map(x => ({ label: x.use === '主图' ? x.label : x.label.replace(/(.*)/, '') }))
}
}
case 'itinerary': {
const dayList = [
{ title: isYn ? '抵达昆明·专车接站' : `抵达${dest}·专车接站`, desc: '落地即接,24h 接站服务,入住酒店后自由活动,推荐逛逛翠湖公园。' },
{ title: isYn ? '石林风景区 → 楚雄' : '核心景区深度游', desc: '上午游览 5A 石林,午后赴楚雄,晚观彝族歌舞表演。' },
{ title: isYn ? '大理古城 → 洱海游船 → 双廊' : '慢节奏主题日', desc: '上午漫步大理古城,午后洱海游船,双廊海边下午茶,看日落。' },
{ title: isYn ? '丽江古城 → 玉龙雪山 → 蓝月谷' : '自由活动日', desc: '索道登玉龙雪山4506m,蓝月谷拍照,晚逛丽江四方街。' },
{ title: isYn ? '束河古镇 → 返回昆明' : '自由活动·休整', desc: '睡到自然醒,逛束河古镇,午后大巴返回昆明。' },
{ title: '返程日·专车送站', desc: '根据航班时间安排送站,行程圆满结束。' }
].slice(0, parseInt(days) || 6)
return {
pre: `按 <span class="hl">${days}·${pType}·${theme}</span> 节奏规划每日行程,张弛有度:`,
card: {
head: '✨ 行程框架(每日含主题与安排)',
lines: dayList.map((d, i) => `<b>D${i + 1}</b> ${d.title}`),
foot: { apply: 'itinerary', label: '⚡ 填入「行程编辑」', regen: true },
tips: [
{ cls: 'ok-pill', text: `每日行车 ≤ 3.5h,适合${theme}` },
{ cls: '', text: 'D1/D6 只做接驳,差评率更低' }
]
},
data: dayList
}
}
case 'price': {
const rows = [
{ date: '2026-09-12', adult: 3299, child: 2299, stock: 18, on: true },
{ date: '2026-09-26', adult: 3499, child: 2399, stock: 12, on: true },
{ date: '2026-10-01', adult: 4199, child: 2899, stock: 25, on: true },
{ date: '2026-10-03', adult: 4199, child: 2899, stock: 5, on: true },
{ date: '2026-10-17', adult: 3399, child: 2349, stock: 20, on: true }
]
return {
pre: `基于近 30 天 <span class="hl">${dest}·${days}·${pType}</span> 成交数据,生成 5 个团期排期与定价(含国庆档):`,
card: {
head: '✨ 价格库存方案',
lines: [
`📊 市场区间:<b>¥2,899-¥4,199</b>(${theme}均价 ¥3,480)`,
`🎯 平季定价:<b>¥3,299</b>(低于均价 5%,点击率预估 +23%)`,
`📈 国庆档:<b>¥4,199</b>·库存充足(预计售罄率 85%)`,
`💡 策略:首月立减 ¥300 冲新品榜,稳定后回调`
],
foot: { apply: 'price', label: '⚡ 填入「价格库存」表', regen: true },
tips: [
{ cls: 'ok-pill', text: '低于均价 5%,排序更优' },
{ cls: 'warn-pill', text: '10-03 团期库存偏低,建议 +10' }
]
},
data: rows
}
}
case 'seo': {
const kw = `${dest}旅游,${dest}${days},${pType},${theme}旅游,${dest}纯玩无购物,${dest}自由行攻略`
const heat = [8200, 6500, 5400, 3900, 2800, 1900]
return {
pre: '提取 <b>6 个高流量关键词</b>,按搜索热度排序:',
card: {
head: '✨ SEO 关键词(月均搜索指数)',
lines: kw.split(',').map((k, i) => `<b>${i + 1}.</b> ${k} <span style="color:#86909c;font-size:11px">热度 ${heat[i]}</span>`),
foot: { apply: null, label: '📋 复制关键词', regen: true },
tips: []
},
data: kw
}
}
case 'check': {
return {
pre: '已对 6 大模块进行 <b>广告法 + 平台规则</b> 双重合规扫描:',
card: {
head: '🛡️ 合规检查报告',
lines: [
'✅ 标题:无「最」「第一」等极限词',
'✅ 详细介绍:无「国家级」「顶级」违禁表述',
'⚠️ 建议:「金牌导游」表述需补充资质证明材料',
'⚠️ 建议:「全额退款」承诺需与退改政策页一致',
'ℹ️ 提示:主图 800×600 以上,避免文字遮挡地标'
],
foot: null,
tips: [
{ cls: 'ok-pill', text: '极限词 0 风险' },
{ cls: 'warn-pill', text: '2 项待补充' },
{ cls: '', text: '审核通过率预估 96%' }
]
},
data: null
}
}
default:
throw new Error('unknown tool type: ' + type)
}
}
-60
View File
@@ -1,60 +0,0 @@
/* ============================================================
* Agent 意图解析层
* 把商家的自然语言(如「帮我发个海南亲子6天自由行」)
* 解析为结构化配置 / 工具调用清单 —— 演示 Agent 的意图理解与编排。
* ============================================================ */
/** 「自主发品」意图识别 */
export const agentIntentRe = /(发品|发布|上架|帮我发|全部生成|全都|六大模块|6大模块|30秒|搞定|一键发)/
/** 目的地/人群 映射表 */
const DEST_MAP = [
['云南', '云南(昆明/大理/丽江)'],
['四川', '四川(成都/九寨沟)'],
['海南', '海南(三亚)'],
['三亚', '海南(三亚)'],
['新疆', '新疆(乌鲁木齐/喀纳斯)'],
['西藏', '西藏(拉萨/林芝)']
]
const THEME_MAP = [
['亲子', '亲子家庭'], ['蜜月', '蜜月情侣'], ['银发', '银发无忧'],
['摄影', '摄影爱好'], ['小众', '小众深度'], ['深度', '小众深度']
]
const TYPES = ['跟团游', '自由行', '定制游', '半自助游', '当地玩乐']
/** 从自然语言里提取配置(目的地/天数/类型/人群) */
export function parseBrief(t) {
const o = {}
for (const [k, v] of DEST_MAP) if (t.includes(k)) { o.dest = v; break }
for (const ty of TYPES) if (t.includes(ty)) { o.type = ty; break }
for (const [k, v] of THEME_MAP) if (t.includes(k)) { o.theme = v; break }
const dm = t.match(/(\d+)\s*[天日]/)
if (dm) {
const n = +dm[1]
o.days = ['3天2晚', '5天4晚', '6天5晚', '7天6晚', '8天7晚'].find(d => d.startsWith(n + '天')) || (n + '天' + (n - 1) + '晚')
}
return o
}
/** 工具关键词表:自然语言 → 工具调用序列(保序去重) */
export const toolKeywords = [
{ type: 'title', kws: ['标题', '名字'] },
{ type: 'intro', kws: ['介绍', '卖点'] },
{ type: 'detail', kws: ['详情'] },
{ type: 'image', kws: ['图', '配图', '拍摄'] },
{ type: 'itinerary', kws: ['行程', '路线', '规划'] },
{ type: 'price', kws: ['价', '定价', '库存', '团期', '多少钱'] },
{ type: 'seo', kws: ['seo', '关键词', '搜索'] },
{ type: 'check', kws: ['合规', '违禁', '广告法'] }
]
/** 从自然语言中提取用户提到的工具(去重、保序) */
export function parseMultiTools(text) {
const lower = text.toLowerCase()
const found = [], seen = new Set()
for (const { type, kws } of toolKeywords) {
if (seen.has(type)) continue
if (kws.some(k => lower.includes(k))) { found.push(type); seen.add(type) }
}
return found
}
-36
View File
@@ -1,36 +0,0 @@
/* ============================================================
* 「越用越好用」偏好学习
* ocStore 持久化到 localStorage:发品次数、沉淀的偏好、最近一次配置。
* ============================================================ */
const OC_KEY = 'fliggy_oc'
export function loadOC() {
try {
return JSON.parse(localStorage.getItem(OC_KEY) || 'null') || { count: 0, prefs: [], last: null }
} catch {
return { count: 0, prefs: [], last: null }
}
}
export function saveOC(store) {
try { localStorage.setItem(OC_KEY, JSON.stringify(store)) } catch { /* 演示环境忽略 */ }
}
/** 成长等级:Lv.1 通用模板 → Lv.2 风格匹配 → Lv.3 爆款复刻 */
export function ocLevelInfo(store) {
if (store.count >= 10) return { name: 'Lv.3 爆款复刻', next: '已解锁:自动复用您的历史爆款结构与定价', pct: 100 }
if (store.count >= 3) return { name: 'Lv.2 风格匹配', next: `再用 ${10 - store.count} 次解锁「Lv.3 爆款复刻」`, pct: (store.count / 10) * 100 }
return { name: 'Lv.1 通用模板', next: `再用 ${3 - store.count} 次解锁「Lv.2 风格匹配」(生成将贴合您的风格)`, pct: (store.count / 10) * 100 }
}
/** 每次发品沉淀的偏好条目 */
export function buildPrefs(destShort, days, type, theme) {
return [
`${destShort}路线偏好:${days}${type}`,
`「${theme}」人群文案风格`,
'纯玩无购物话术偏好',
'平季低于市场 5% 定价策略',
'首月立减冲榜促销习惯'
]
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/global.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
-10
View File
@@ -1,10 +0,0 @@
import { createContext, useContext } from 'react'
/** 全局 Store 上下文:App 中用 useAppStore() 创建并注入 */
export const StoreContext = createContext(null)
export function useStore() {
const store = useContext(StoreContext)
if (!store) throw new Error('useStore 必须在 <StoreContext.Provider> 内使用')
return store
}
-541
View File
@@ -1,541 +0,0 @@
/* ============================================================
* 应用状态中枢(useAppStore)
* - 表单状态 / 会话消息 / 生成结果缓存 / 串行确认 / 一键发品弹窗
* - 异步编排流(quickAction / agentRun / multiToolRun / startOC)
* 全部通过 ref 读取最新状态,避免闭包过期问题
* ============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { buildGen, agentSteps, ocStepsDef, actionLabel, cardMap } from '../data/genData'
import { parseBrief, parseMultiTools, agentIntentRe } from '../lib/agent'
import { loadOC, saveOC, ocLevelInfo, buildPrefs } from '../lib/oc'
const delay = ms => new Promise(r => setTimeout(r, ms))
const esc = s => s.replace(/</g, '&lt;')
let msgSeq = 0
const INITIAL_FORM = {
title: '',
dest: '云南(昆明/大理/丽江)',
days: '6天5晚',
type: '跟团游',
theme: '亲子家庭',
intro: ''
}
const WELCOME_1 = `您好,我是 <b>旅智小助</b> 🤖<br>我对应左侧 6 大表单模块,可以帮您:<br>· 📝 商品标题 — 3 个方案一键切换填入<br>· 💎 详细介绍 — 四段式卖点文案<br>· 📄 商品详情 — 结构化图文(含配图占位)<br>· 🖼️ 配图建议 — 6 张图拍摄脚本<br>· 🗺️ 行程编辑 — 逐日行程一键填入<br>· 💰 价格库存 — 团期排期 + 定价策略<br><br>💬 <b>自主发品</b>:说「<b>帮我优化标题和详情</b>」或「<b>帮我发个云南亲子 6 天跟团游</b>」,我会<b>逐个工具生成 → 展示卡片 → 等您确认</b>再填入表单(⚡自动 5s 确认,可切✋手动)~`
const WELCOME_2 = `💡 <b>演示提示</b>:<br>· 说「<b>帮我优化标题和详情</b>」→ 串行调度指定模块<br>· 说「<b>帮我发个海南亲子 6 天自由行</b>」→ 自主调用全部 8 个工具<br>· 每步<b>生成卡片 → 等您确认</b>再填入(⚡自动 5s / ✋手动可随时切换)<br>· 也可点 <b>⚡ 一键发品</b> 用弹窗方式完成<br>都会沉淀偏好,<b>用得越多越懂你</b>。`
const CANNED = [
{ k: ['标题', '名字'], type: 'title' },
{ k: ['介绍', '卖点'], type: 'intro' },
{ k: ['详情'], type: 'detail' },
{ k: ['图', '配图', '拍摄'], type: 'image' },
{ k: ['行程', '规划', '路线'], type: 'itinerary' },
{ k: ['价', '定价', '库存', '团期', '多少钱'], type: 'price' },
{ k: ['seo', '关键词', '搜索'], type: 'seo' },
{ k: ['合规', '违禁', '广告法'], type: 'check' }
]
export function useAppStore() {
/* ---------------- 基础状态 ---------------- */
const [form, setForm] = useState(INITIAL_FORM)
const [images, setImages] = useState([])
const [itineraryDays, setItineraryDays] = useState([])
const [priceRows, setPriceRows] = useState([])
const [messages, setMessages] = useState([])
const [panelCollapsed, setPanelCollapsed] = useState(false)
const [toastState, setToastState] = useState(null) // { text, ts }
const [countdown, setCountdown] = useState(0) // 串行确认倒计时(秒)
const [autoConfirmMode, setAutoConfirmMode] = useState(true)
const [ocStore, setOcStore] = useState(loadOC) // 偏好学习(localStorage 持久化)
const [titleHighlight, setTitleHighlight] = useState(false)
const [introHighlight, setIntroHighlight] = useState(false)
const [ocModal, setOcModal] = useState({
open: false, view: 'config', // config | progress | done
dest: '云南(昆明/大理/丽江)', days: '6天5晚', type: '跟团游', theme: '亲子家庭',
steps: [], learnVisible: false, learnHtml: '', doneHtml: ''
})
/* ---------------- Ref 镜像(供异步流读取最新值) ---------------- */
const formRef = useRef(INITIAL_FORM)
formRef.current = form
const generatedRef = useRef({}) // 每类工具最新生成结果 { data, used }
const serialRef = useRef(null) // { resolve, msgId, index, type, mode }
const autoConfirmRef = useRef(true)
const silentBatchRef = useRef(false) // 一键发品批量模式:静默填入
const detailRef = useRef(null) // 商品详情 contenteditable DOM
const formAreaRef = useRef(null) // 左侧表单滚动容器
const sectionRefs = useRef({}) // 表单卡片锚点 { 'card-title': HTMLElement }
const timerRef = useRef(null)
const ocStoreRef = useRef(ocStore)
ocStoreRef.current = ocStore
const ocModalRef = useRef(ocModal)
ocModalRef.current = ocModal
/* ---------------- 基础动作 ---------------- */
const showToast = useCallback(text => {
if (silentBatchRef.current) return
setToastState({ text: '✓ ' + text, ts: Date.now() })
}, [])
const flashSection = useCallback(cardId => {
if (silentBatchRef.current) return
const el = sectionRefs.current[cardId]
if (!el) return
el.classList.add('section-highlight')
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => el.classList.remove('section-highlight'), 1800)
}, [])
/** 读取当前表单上下文(生成依赖表单配置) */
const readForm = useCallback(() => {
const f = formRef.current
return {
dest: f.dest.replace(/(.*)/, '') || '云南',
days: f.days || '6天5晚',
type: f.type || '跟团游',
theme: f.theme || '亲子家庭'
}
}, [])
/* ---------------- 消息流 ---------------- */
const addMsg = useCallback((role, html) => {
const id = 'm' + (++msgSeq)
setMessages(ms => [...ms, { id, role, kind: 'html', html }])
return id
}, [])
const addTyping = useCallback(() => {
const id = 'm' + (++msgSeq)
setMessages(ms => [...ms, { id, role: 'ai', kind: 'typing' }])
return id
}, [])
const patchMsg = useCallback((id, patch) => {
setMessages(ms => ms.map(m => (m.id === id ? { ...m, ...patch } : m)))
}, [])
/** 替换 typing 占位为正式内容 */
const replaceTyping = useCallback((id, patch) => {
patchMsg(id, { kind: patch.kind || 'html', ...patch })
}, [patchMsg])
/* ---------------- 表单写入(applyMap 的 React 版) ---------------- */
const applyGen = useCallback(type => {
const gen = generatedRef.current[type]
if (!gen) return
switch (type) {
case 'title': {
const opts = gen.data
const idx = gen.used || 0
const t = opts[idx % opts.length]
generatedRef.current[type] = { ...gen, used: idx + 1 }
formRef.current = { ...formRef.current, title: t }
setForm(f => ({ ...f, title: t }))
setTitleHighlight(true)
setTimeout(() => setTitleHighlight(false), 1600)
flashSection('card-title')
showToast('已填入商品标题(方案 ' + ((idx % opts.length) + 1) + ')')
break
}
case 'intro': {
formRef.current = { ...formRef.current, intro: gen.data }
setForm(f => ({ ...f, intro: gen.data }))
setIntroHighlight(true)
setTimeout(() => setIntroHighlight(false), 1600)
flashSection('card-intro')
showToast('已填入详细介绍')
break
}
case 'detail': {
if (detailRef.current) detailRef.current.innerHTML = gen.data
flashSection('card-detail')
showToast('已生成商品详情图文结构')
break
}
case 'image': {
setImages(prev => [...prev, ...gen.data.map(im => ({ label: im.label, ai: true }))])
flashSection('card-img')
showToast('已按 AI 清单添加 ' + gen.data.length + ' 张配图')
break
}
case 'itinerary': {
setItineraryDays(gen.data.map(d => ({ ...d })))
flashSection('card-itinerary')
showToast('已填入 ' + gen.data.length + ' 天行程')
break
}
case 'price': {
setPriceRows(gen.data.map(r => ({ ...r })))
flashSection('card-price')
showToast('已生成 ' + gen.data.length + ' 个团期')
break
}
default:
break
}
}, [flashSection, showToast])
/* ---------------- 串行确认机制 ---------------- */
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null }
}, [])
const startCountdown = useCallback(() => {
if (!serialRef.current || !serialRef.current.mode) return
stopTimer()
let remaining = 5
setCountdown(5)
timerRef.current = setInterval(() => {
remaining--
if (remaining <= 0) {
stopTimer()
setCountdown(0)
const s = serialRef.current
serialRef.current = null
if (s) s.resolve()
} else {
setCountdown(remaining)
}
}, 1000)
}, [stopTimer])
const serialConfirm = useCallback(() => {
const s = serialRef.current
if (!s) return
stopTimer()
setCountdown(0)
serialRef.current = null
patchMsg(s.msgId, { confirm: { index: s.index, mode: s.mode, status: 'confirmed' } })
s.resolve()
}, [patchMsg, stopTimer])
const serialRegen = useCallback(() => {
const s = serialRef.current
if (!s) return
stopTimer()
const g = buildGen(s.type, readForm())
generatedRef.current[s.type] = { data: g.data, used: 0 }
patchMsg(s.msgId, { gen: { type: s.type, pre: g.pre, card: g.card } })
if (s.mode) startCountdown()
}, [patchMsg, readForm, startCountdown, stopTimer])
const setAutoConfirm = useCallback(val => {
autoConfirmRef.current = val
setAutoConfirmMode(val)
const s = serialRef.current
if (!s) return
stopTimer()
s.mode = val
if (val) startCountdown()
else setCountdown(0)
}, [startCountdown, stopTimer])
/** 串行流程的单步:生成卡片 → 等待确认(Promise) */
const showStepCard = useCallback(async (taskId, index, type, autoMode) => {
const tp = addTyping()
await delay(500 + Math.random() * 400)
const g = buildGen(type, readForm())
generatedRef.current[type] = { data: g.data, used: 0 }
patchMsg(tp, {
kind: 'gen',
gen: { type, pre: g.pre, card: g.card },
confirm: { taskId, index, mode: autoMode, status: 'waiting' }
})
await new Promise(resolve => {
serialRef.current = { resolve, msgId: tp, taskId, index, type, mode: autoMode }
if (autoMode) startCountdown()
})
}, [addTyping, patchMsg, readForm, startCountdown])
/* ---------------- 工具任务清单卡片 ---------------- */
const addTaskCard = useCallback((title, ctxLine, steps) => {
const id = 'm' + (++msgSeq)
setMessages(ms => [...ms, {
id, role: 'ai', kind: 'task',
task: {
title, ctxLine,
steps: steps.map(s => ({ label: s.label, note: '等待中', status: 'todo' }))
}
}])
return id
}, [])
const setTaskStep = useCallback((taskId, i, status, note) => {
setMessages(ms => ms.map(m => {
if (m.id !== taskId) return m
const steps = m.task.steps.map((s, j) => (j === i ? { ...s, status, note: note ?? s.note } : s))
return { ...m, task: { ...m.task, steps } }
}))
}, [])
/* ---------------- 快捷动作 / 对话编排 ---------------- */
const quickAction = useCallback(async (type, silent) => {
if (!silent) addMsg('user', '点击了「' + actionLabel[type] + '」')
const tp = addTyping()
await delay(900 + Math.random() * 600)
const g = buildGen(type, readForm())
generatedRef.current[type] = { data: g.data, used: 0 }
replaceTyping(tp, { kind: 'gen', gen: { type, pre: g.pre, card: g.card } })
}, [addMsg, addTyping, readForm, replaceTyping])
/** 表单模块内嵌 AI 按钮:面板收起时自动展开 */
const formAI = useCallback(type => {
setPanelCollapsed(false)
quickAction(type)
}, [quickAction])
const regen = useCallback(type => quickAction(type, true), [quickAction])
/** 把解析出的配置回写左侧表单 */
const applyBriefToForm = useCallback(b => {
const patch = {}
if (b.dest) patch.dest = b.dest
if (b.days) patch.days = b.days
if (b.type) patch.type = b.type
if (b.theme) patch.theme = b.theme
if (Object.keys(patch).length) {
formRef.current = { ...formRef.current, ...patch }
setForm(f => ({ ...f, ...patch }))
}
}, [])
/** 记录一次发品,沉淀偏好(与一键发品共用记忆) */
const recordPublish = useCallback((destShort, days, type, theme) => {
const prefs = buildPrefs(destShort, days, type, theme)
const prev = ocStoreRef.current
const count = prev.count + 1
const next = {
count,
prefs: prev.prefs.concat(prefs),
last: {
dest: destShort, days, type, theme,
destSel: formRef.current.dest, daysSel: days, typeSel: type,
styleWord: count >= 3 ? `${theme}友好·${count >= 10 ? '爆款结构复刻' : '您的惯用话术'}` : '纯玩无购物'
}
}
ocStoreRef.current = next
saveOC(next)
setOcStore(next)
return { prefs, count }
}, [])
/** 自主发品 Agent:8 工具全量编排 */
const agentRun = useCallback(async rawText => {
const b = parseBrief(rawText)
applyBriefToForm(b)
const f = readForm()
const ctxLine = `${f.dest} · ${f.days} · ${f.type} · ${f.theme}`
addMsg('ai', `🚀 好的,我来自主完成这次发品。<br>已识别配置:<b>${ctxLine}</b>${b.dest ? '(来自您的描述)' : '(沿用当前表单)'}。<br>我将依次调用 <b>8 个工具</b>,每个工具生成后<b>展示卡片等您确认</b>再填入表单:`)
const taskId = addTaskCard('🤖 自主发品任务清单', `📋 上下文:${ctxLine} | 预计 8 步`, agentSteps)
for (let i = 0; i < agentSteps.length; i++) {
const s = agentSteps[i]
setTaskStep(taskId, i, 'doing', '生成中…')
await showStepCard(taskId, i, s.type, autoConfirmRef.current)
applyGen(s.type)
setTaskStep(taskId, i, 'done', s.note)
}
// 沉淀偏好(越用越好用)
const { prefs, count } = recordPublish(f.dest, f.days, f.type, f.theme)
formAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
const lv = ocLevelInfo({ count, prefs: [], last: null })
addMsg('ai', `✅ <b>自主发品完成!</b>6 大模块已全部填入左侧表单,另完成 SEO 关键词提取与合规扫描。<br>本次沉淀偏好 <b>${prefs.length} 条</b>,累计发品 <b>${count} 次</b>(${lv.name})。<br>可直接点顶部「提交审核」,或对任一模块点「🔄 换一版」微调。`)
}, [addMsg, addTaskCard, applyBriefToForm, applyGen, readForm, recordPublish, setTaskStep, showStepCard])
/** 多工具串行编排:仅跑用户指定的模块 */
const multiToolRun = useCallback(async (rawText, toolTypes) => {
const b = parseBrief(rawText)
if (Object.keys(b).length) applyBriefToForm(b)
const f = readForm()
const ctxLine = `${f.dest} · ${f.days} · ${f.type} · ${f.theme}`
const steps = agentSteps.filter(s => toolTypes.includes(s.type))
const labels = steps.map(s => s.label).join(' → ')
addMsg('ai', `🛠️ 好的,我来自主调度 <b>${steps.length}</b> 个模块。<br>已识别上下文:<b>${ctxLine}</b>${b.dest ? '(来自您的描述)' : '(沿用当前表单)'}。<br>将依次执行:<b>${labels}</b>,每个工具生成后<b>展示卡片等您确认</b>再填入表单:`)
const taskId = addTaskCard('🛠️ 多工具串行任务', `📋 ${ctxLine} | 共 ${steps.length} 步`, steps)
for (let i = 0; i < steps.length; i++) {
const s = steps[i]
setTaskStep(taskId, i, 'doing', '生成中…')
await showStepCard(taskId, i, s.type, autoConfirmRef.current)
applyGen(s.type)
setTaskStep(taskId, i, 'done', s.note)
}
const first = cardMap[steps[0].type]
if (first) sectionRefs.current[first]?.scrollIntoView({ behavior: 'smooth', block: 'center' })
addMsg('ai', `✅ <b>${steps.length} 个模块生成完毕!</b>${steps.map(s => s.label).join('、')} 已填入左侧表单。<br>不满意可点对应模块的「🔄 换一版」微调,或说「<b>帮我把这个商品发布了</b>」让我一口气完成全部模块。`)
}, [addMsg, addTaskCard, applyBriefToForm, applyGen, readForm, setTaskStep, showStepCard])
/** 自由对话入口:意图识别 → 多工具编排 / 全量 Agent / 单工具 / 闲聊 */
const sendMessage = useCallback(async text => {
text = (text || '').trim()
if (!text) return
// 若有串行步骤在等待确认,自动确认后继续处理新消息
if (serialRef.current) serialConfirm()
addMsg('user', esc(text))
const tp = addTyping()
await delay(700)
// 优先:多工具串行(用户同时提到 2+ 模块)
const tools = parseMultiTools(text)
if (tools.length >= 2) {
replaceTyping(tp, { html: `明白,我来自主调度 ${tools.length} 个模块 🛠️` })
await delay(400)
multiToolRun(text, tools)
return
}
// 全量自主发品
if (agentIntentRe.test(text)) {
replaceTyping(tp, { html: '明白,我来自主完成这次发品 🚀' })
await delay(400)
agentRun(text)
return
}
const hit = CANNED.find(c => c.k.some(k => text.toLowerCase().includes(k)))
if (hit) {
replaceTyping(tp, { html: '好的,马上为您处理 👇' })
await delay(400)
quickAction(hit.type)
return
}
const ctx = readForm()
const replies = [
`收到!我已读取当前表单:目的地 <span class="hl">${ctx.dest}</span>、类型 <span class="hl">${ctx.type}</span>。您可以点上方快捷按钮生成内容,或直接说需求,例如:<br>·「帮我写个适合${ctx.theme}的标题」<br>·「国庆团期怎么定价」`,
`关于「${esc(text.slice(0, 12))}${text.length > 12 ? '…' : ''}」,建议先在左侧表单选好目的地和天数,我生成的内容会更精准。也可以试试快捷区的 <b>价格库存</b>,我会基于市场数据给出排期方案~`,
`我可以帮您拆解:先定目标人群(${ctx.theme}?蜜月?),再定核心卖点(纯玩/性价比/体验感),然后按框架生成标题→介绍→详情→行程。直接点快捷按钮即可开始 ✨`
]
replaceTyping(tp, { html: replies[Math.floor(Math.random() * replies.length)] })
}, [addMsg, addTyping, agentRun, multiToolRun, quickAction, readForm, replaceTyping, serialConfirm])
/* ---------------- 一键发品(越用越好用) ---------------- */
const openOC = useCallback(() => {
const store = ocStoreRef.current
const last = store.last
setOcModal(prev => {
const next = { ...prev, open: true, view: 'config', steps: [], learnVisible: false, learnHtml: '', doneHtml: '' }
if (last) {
if (last.destSel) next.dest = last.destSel
if (last.daysSel) next.days = last.daysSel
if (last.typeSel) next.type = last.typeSel
if (last.theme) next.theme = last.theme
}
if (store.count > 0 && last) {
next.learnVisible = true
next.learnHtml = `🧠 <b>越用越好用</b>:已根据您最近 ${store.count} 次发品习惯<b>自动预填</b>(${last.dest}·${last.days}·${last.type}·${last.theme}),生成内容将沿用您偏好的「${last.styleWord}」风格。`
}
return next
})
}, [])
const closeOC = useCallback(() => {
setOcModal(prev => ({ ...prev, open: false }))
}, [])
const setOCField = useCallback((k, v) => {
setOcModal(prev => ({ ...prev, [k]: v }))
}, [])
const startOC = useCallback(async () => {
const cfg = ocModalRef.current
const { dest: destSel, days: daysSel, type: typeSel, theme } = cfg
// 同步配置到左侧表单(生成依赖表单上下文)
applyBriefToForm({ dest: destSel, days: daysSel, type: typeSel, theme })
setOcModal(prev => ({ ...prev, view: 'progress', steps: ocStepsDef.map(() => ({ status: 'todo' })) }))
silentBatchRef.current = true
for (let i = 0; i < ocStepsDef.length; i++) {
const s = ocStepsDef[i]
setOcModal(prev => ({ ...prev, steps: prev.steps.map((x, j) => (j === i ? { status: 'doing' } : x)) }))
await delay(650 + Math.random() * 450)
const g = buildGen(s.type, readForm())
generatedRef.current[s.type] = { data: g.data, used: 0 }
applyGen(s.type)
setOcModal(prev => ({ ...prev, steps: prev.steps.map((x, j) => (j === i ? { status: 'done' } : x)) }))
}
silentBatchRef.current = false
// 沉淀偏好(越用越好用的核心)
const dest = destSel.replace(/(.*)/, '')
const { prefs, count } = recordPublish(dest, daysSel, typeSel, theme)
const lv = ocLevelInfo({ count, prefs: [], last: null })
const doneHtml = `🧠 <b>本次为您沉淀 ${prefs.length} 条偏好</b>(累计 ${ocStoreRef.current.prefs.length} 条):<br>` +
prefs.map(p => '· ' + p).join('<br>') +
`<br><b>${lv.name}</b> · ${lv.next}<br>下次一键发品将自动预填并按您的风格生成。`
setOcModal(prev => ({ ...prev, view: 'done', doneHtml }))
showToast('6 大模块已生成完毕')
addMsg('ai', `已完成 <b>一键发品</b> ✅ 标题、介绍、详情、配图、行程、价格库存全部就位。<br>本次沉淀偏好 <b>${ocStoreRef.current.prefs.length} 条</b>,累计发品 <b>${count} 次</b>——用得越多,生成越贴合您的风格。<br>任何模块不满意,点对应快捷按钮「🔄 换一版」即可。`)
}, [addMsg, applyBriefToForm, applyGen, readForm, recordPublish, showToast])
/* ---------------- 表单交互 ---------------- */
const updateForm = useCallback((k, v) => {
formRef.current = { ...formRef.current, [k]: v }
setForm(f => ({ ...f, [k]: v }))
}, [])
const uploadImg = useCallback(() => {
setImages(prev => [...prev, { label: '新上传图片', ai: false }])
showToast('已添加图片占位(演示)')
}, [showToast])
const removeImage = useCallback(i => setImages(prev => prev.filter((_, j) => j !== i)), [])
const addDay = useCallback(() => setItineraryDays(prev => [...prev, { title: '', desc: '' }]), [])
const removeDay = useCallback(i => setItineraryDays(prev => prev.filter((_, j) => j !== i)), [])
const updateDay = useCallback((i, k, v) => {
setItineraryDays(prev => prev.map((d, j) => (j === i ? { ...d, [k]: v } : d)))
}, [])
const addPriceRow = useCallback(() => setPriceRows(prev => [...prev, { date: '2026-10-01', adult: 3299, child: 2299, stock: 20, on: true }]), [])
const removePriceRow = useCallback(i => setPriceRows(prev => prev.filter((_, j) => j !== i)), [])
const updatePriceRow = useCallback((i, k, v) => {
setPriceRows(prev => prev.map((r, j) => (j === i ? { ...r, [k]: v } : r)))
}, [])
const togglePriceOn = useCallback(i => {
setPriceRows(prev => prev.map((r, j) => (j === i ? { ...r, on: !r.on } : r)))
}, [])
/* ---------------- 初始欢迎消息 ---------------- */
useEffect(() => {
let alive = true
;(async () => {
const tp1 = addTyping()
await delay(700)
if (!alive) return
replaceTyping(tp1, { html: WELCOME_1 })
await delay(600)
if (!alive) return
const tp2 = addTyping()
await delay(900)
if (!alive) return
replaceTyping(tp2, { html: WELCOME_2 })
})()
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {
// 状态
form, images, itineraryDays, priceRows, messages, panelCollapsed,
toastState, countdown, autoConfirmMode, ocStore, ocModal,
titleHighlight, introHighlight,
// refs(组件注册 DOM 用)
detailRef, sectionRefs, formAreaRef,
// 动作
updateForm, uploadImg, removeImage,
addDay, removeDay, updateDay,
addPriceRow, removePriceRow, updatePriceRow, togglePriceOn,
setPanelCollapsed, applyGen, regen, formAI, quickAction,
sendMessage, setAutoConfirm, serialConfirm, serialRegen,
openOC, closeOC, setOCField, startOC,
showToast
}
}
-463
View File
@@ -1,463 +0,0 @@
/*
* 旅智工作台 · 商品发布 AI 助手 —— 全局样式
* 从 v1 单文件版(根目录 index.html)完整迁移,类名与选择器保持不变,
* 保证 React 版与原始 Demo 视觉/交互完全一致。
*/
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: #f0f2f5; color: #1f2329;
height: 100vh; overflow: hidden;
display: flex; flex-direction: column;
}
/* ============ 顶部导航 ============ */
.topbar {
height: 52px; background: #fff; border-bottom: 1px solid #e5e6eb;
display: flex; align-items: center; padding: 0 20px; flex-shrink: 0; z-index: 10;
}
.topbar .logo { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 15px; color: #d96a00; }
.topbar .logo .icon {
width: 26px; height: 26px; background: linear-gradient(135deg, #ff7a1a, #ffb300);
border-radius: 7px; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 14px;
}
.topbar .breadcrumb { margin-left: 24px; font-size: 13px; color: #86909c; }
.topbar .breadcrumb b { color: #1f2329; font-weight: 500; }
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 16px; font-size: 13px; color: #4e5969; }
.avatar-sm {
width: 30px; height: 30px; border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 12px;
}
/* ============ 主体布局 ============ */
.main { flex: 1; display: flex; overflow: hidden; }
/* ============ 左侧:表单区 ============ */
.form-area { flex: 1; overflow-y: auto; padding: 20px 24px 60px; }
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-head h1 { font-size: 20px; font-weight: 600; }
.page-head .sub { font-size: 13px; color: #86909c; margin-top: 4px; }
.btn-primary {
background: linear-gradient(135deg, #ff7a1a, #ff6600); color: #fff; border: none;
padding: 9px 22px; border-radius: 8px; font-size: 14px; cursor: pointer;
font-weight: 500; transition: opacity .2s;
}
.btn-primary:hover { opacity: .88; }
.card {
background: #fff; border-radius: 12px; border: 1px solid #e5e6eb;
padding: 24px; margin-bottom: 16px;
}
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 18px; display: flex; align-items: center; gap: 8px; }
.card h2 .dot { width: 4px; height: 14px; background: #ff7a1a; border-radius: 2px; }
.card h2 .badge-step {
margin-left: auto; font-size: 11px; font-weight: 500; color: #86909c;
background: #f7f8fa; border-radius: 12px; padding: 3px 10px;
}
/* 表单模块内嵌 AI 按钮 */
.ai-inline-btn {
margin-left: 8px; font-size: 12px; font-weight: 500;
color: #ff7a1a; background: #fff; border: 1px solid rgba(255, 122, 26, 0.5);
border-radius: 12px; padding: 3px 10px; cursor: pointer;
display: inline-flex; align-items: center; gap: 3px;
transition: all .15s ease; white-space: nowrap;
}
.ai-inline-btn:hover {
background: #ff7a1a; color: #fff; border-color: #ff7a1a;
box-shadow: 0 2px 8px rgba(255, 122, 26, 0.35);
}
.form-row { display: flex; gap: 16px; margin-bottom: 16px; }
.form-item { flex: 1; }
.form-item:last-child { margin-bottom: 0; }
.form-item label { display: block; font-size: 13px; color: #4e5969; margin-bottom: 6px; }
.form-item label .req { color: #f53f3f; margin-right: 2px; }
.form-item input, .form-item select, .form-item textarea {
width: 100%; border: 1px solid #e5e6eb; border-radius: 8px; padding: 9px 12px;
font-size: 13px; background: #fff; transition: border-color .2s, box-shadow .2s; font-family: inherit;
}
.form-item input:focus, .form-item select:focus, .form-item textarea:focus {
outline: none; border-color: #ff7a1a; box-shadow: 0 0 0 3px rgba(255,122,26,.12);
}
.form-item textarea { resize: vertical; min-height: 92px; line-height: 1.7; }
.form-item .hint { font-size: 12px; color: #c9cdd4; margin-top: 4px; }
.counter { font-size: 12px; color: #c9cdd4; margin-top: 4px; text-align: right; }
.field-highlight { border-color: #ff7a1a !important; box-shadow: 0 0 0 3px rgba(255,122,26,.18) !important; }
.section-highlight { outline: 2px solid #ff7a1a; outline-offset: 2px; border-radius: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
border: 1px solid #e5e6eb; border-radius: 16px; padding: 5px 14px; font-size: 12px;
color: #4e5969; cursor: pointer; transition: all .2s; background: #fff;
}
.chip:hover { border-color: #ff7a1a; color: #d96a00; }
.chip.active { background: rgba(255,122,26,.08); border-color: #ff7a1a; color: #d96a00; }
/* 富文本编辑器 */
.rich-editor { border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; }
.rich-toolbar {
display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid #f2f3f5; background: #fafbfc;
}
.rich-toolbar button {
border: none; background: transparent; width: 28px; height: 26px; border-radius: 6px;
cursor: pointer; font-size: 13px; color: #4e5969;
}
.rich-toolbar button:hover { background: #eef1f4; }
.rich-toolbar .sep { width: 1px; background: #e5e6eb; margin: 3px 4px; }
.rich-content {
min-height: 150px; padding: 12px 14px; font-size: 13px; line-height: 1.9; outline: none;
}
.rich-content:empty::before { content: attr(data-placeholder); color: #c9cdd4; }
.rich-content h3 { font-size: 14px; margin: 10px 0 6px; color: #d96a00; }
.rich-content ul { padding-left: 20px; }
.rich-content li { margin-bottom: 4px; }
.rich-content .img-slot {
display: flex; align-items: center; justify-content: center; gap: 6px;
height: 60px; border-radius: 8px; margin: 8px 0; font-size: 12px; color: #86909c;
background: repeating-linear-gradient(45deg, #f7f8fa, #f7f8fa 10px, #f2f3f5 10px, #f2f3f5 20px);
border: 1px dashed #d5d9e0;
}
/* 图片上传 */
.img-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.img-tile {
aspect-ratio: 4/3; border-radius: 10px; position: relative; overflow: hidden;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; color: #fff; font-size: 13px; cursor: default;
}
.img-tile .em { font-size: 26px; }
.img-tile .tag {
position: absolute; top: 6px; left: 6px; font-size: 10px; background: rgba(0,0,0,.35);
padding: 2px 7px; border-radius: 10px; backdrop-filter: blur(2px);
}
.img-tile .ai-tag {
position: absolute; top: 6px; right: 6px; font-size: 10px; background: rgba(255,122,26,.9);
padding: 2px 7px; border-radius: 10px;
}
.img-tile .del {
position: absolute; bottom: 6px; right: 6px; width: 20px; height: 20px; border-radius: 50%;
background: rgba(0,0,0,.4); color: #fff; border: none; font-size: 11px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.img-upload {
aspect-ratio: 4/3; border: 1.5px dashed #c9cdd4; border-radius: 10px; display: flex;
flex-direction: column; align-items: center; justify-content: center; gap: 6px;
color: #86909c; font-size: 12px; cursor: pointer; transition: all .2s; background: #fafbfc;
}
.img-upload:hover { border-color: #ff7a1a; color: #d96a00; }
.img-upload .plus { font-size: 24px; color: #c9cdd4; }
/* 行程编辑 */
.day-card { border: 1px solid #e5e6eb; border-radius: 10px; margin-bottom: 10px; overflow: hidden; }
.day-head {
display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: #fafbfc;
border-bottom: 1px solid #f2f3f5;
}
.day-head .dnum {
background: linear-gradient(135deg, #ff7a1a, #ffb300); color: #fff; font-size: 12px;
font-weight: 600; border-radius: 6px; padding: 3px 10px; flex-shrink: 0;
}
.day-head input {
flex: 1; border: none; outline: none; font-size: 13px; background: transparent; font-family: inherit;
}
.day-head .del-day {
border: none; background: transparent; color: #c9cdd4; cursor: pointer; font-size: 14px;
}
.day-head .del-day:hover { color: #f53f3f; }
.day-body { padding: 10px 12px; }
.day-body textarea {
width: 100%; border: none; outline: none; resize: vertical; font-size: 12px; line-height: 1.7;
min-height: 44px; font-family: inherit; background: transparent; color: #4e5969;
}
.add-day {
width: 100%; border: 1.5px dashed #c9cdd4; background: #fafbfc; border-radius: 10px;
padding: 10px; color: #86909c; font-size: 13px; cursor: pointer; transition: all .2s;
}
.add-day:hover { border-color: #ff7a1a; color: #d96a00; }
/* 价格库存表 */
.price-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.price-table th {
text-align: left; padding: 8px 10px; background: #fafbfc; color: #86909c;
font-weight: 500; font-size: 12px; border-bottom: 1px solid #f2f3f5;
}
.price-table td { padding: 6px 8px; border-bottom: 1px solid #f7f8fa; }
.price-table input {
width: 100%; border: 1px solid transparent; border-radius: 6px; padding: 6px 8px;
font-size: 13px; font-family: inherit; background: transparent;
}
.price-table input:hover, .price-table input:focus { border-color: #e5e6eb; background: #fff; outline: none; }
.price-table input:focus { border-color: #ff7a1a; }
.stock-low { color: #b45309; }
.status-pill { font-size: 11px; border-radius: 10px; padding: 2px 9px; }
.status-on { background: rgba(34,197,94,.1); color: #15803d; }
.status-off { background: #f2f3f5; color: #86909c; }
.del-row { border: none; background: transparent; color: #c9cdd4; cursor: pointer; font-size: 14px; }
.del-row:hover { color: #f53f3f; }
.empty-tip { padding: 18px; text-align: center; color: #c9cdd4; font-size: 13px; }
/* ============ 右侧:AI 助手 ============ */
.ai-panel {
width: 400px; background: #fff; border-left: 1px solid #e5e6eb;
display: flex; flex-direction: column; position: relative;
transition: margin-right .35s ease; flex-shrink: 0;
}
.ai-panel.collapsed { margin-right: -400px; }
.ai-header {
padding: 14px 16px; border-bottom: 1px solid #f2f3f5; display: flex; align-items: center; gap: 10px;
background: linear-gradient(135deg, rgba(255,122,26,.05), rgba(255,178,0,.05)); flex-shrink: 0;
}
.ai-avatar {
width: 38px; height: 38px; border-radius: 10px;
background: linear-gradient(135deg, #ff7a1a, #ffb300);
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 18px;
position: relative; flex-shrink: 0;
}
.ai-avatar::after {
content: ""; position: absolute; right: -2px; bottom: -2px; width: 10px; height: 10px;
background: #22c55e; border: 2px solid #fff; border-radius: 50%;
}
.ai-header .info { flex: 1; }
.ai-header .name { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.ai-header .name .badge {
font-size: 10px; background: rgba(255,122,26,.1); color: #d96a00;
padding: 2px 6px; border-radius: 4px; font-weight: 500;
}
.ai-header .status { font-size: 12px; color: #86909c; margin-top: 2px; }
.icon-btn {
width: 30px; height: 30px; border: 1px solid #e5e6eb; background: #fff; border-radius: 8px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #86909c; font-size: 14px; transition: all .2s;
}
.icon-btn:hover { color: #d96a00; border-color: #ff7a1a; }
.ai-quick { padding: 12px 14px 4px; border-bottom: 1px solid #f2f3f5; flex-shrink: 0; }
.ai-quick .title { font-size: 12px; color: #86909c; margin-bottom: 8px; }
.quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding-bottom: 10px; }
.quick-btn {
border: 1px solid #e5e6eb; border-radius: 10px; background: #fff; padding: 9px 2px;
cursor: pointer; text-align: center; transition: all .2s;
display: flex; flex-direction: column; align-items: center; gap: 5px;
}
.quick-btn .qicon { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 15px; }
.quick-btn .qtext { font-size: 12px; color: #4e5969; }
.quick-btn:hover { border-color: #ff7a1a; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(255,122,26,.12); }
.q1 { background: rgba(255,122,26,.1); }
.q2 { background: rgba(255,178,0,.1); }
.q3 { background: rgba(245,158,11,.12); }
.q4 { background: rgba(139,92,246,.1); }
.q5 { background: rgba(236,72,153,.08); }
.q6 { background: rgba(34,197,94,.1); }
.q7 { background: rgba(99,102,241,.1); }
.ai-messages { flex: 1; overflow-y: auto; padding: 16px 14px; display: flex; flex-direction: column; gap: 14px; }
.msg { display: flex; gap: 8px; max-width: 100%; animation: msgIn .3s ease; }
@keyframes msgIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.msg .m-avatar {
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 13px; color: #fff;
}
.msg.ai .m-avatar { background: linear-gradient(135deg, #ff7a1a, #ffb300); }
.msg.user { flex-direction: row-reverse; }
.msg.user .m-avatar { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
.msg .bubble {
background: #f7f8fa; border-radius: 12px; padding: 10px 12px; font-size: 13px;
line-height: 1.75; color: #1f2329; max-width: calc(100% - 40px); word-break: break-word;
}
.msg.user .bubble { background: linear-gradient(135deg, #ff7a1a, #ff6600); color: #fff; }
.bubble b { font-weight: 600; }
.bubble .hl { color: #d96a00; font-weight: 600; }
.gen-card {
background: #fff; border: 1px solid #ffe3c2; border-radius: 12px; padding: 12px;
margin-top: 8px; box-shadow: 0 2px 8px rgba(15,23,42,.04);
}
.gen-card .gc-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #d96a00; font-weight: 600; margin-bottom: 8px; }
.gen-card .gc-body { font-size: 13px; line-height: 1.8; color: #1f2329; background: #f8fafb; border-radius: 8px; padding: 10px 12px; max-height: 260px; overflow-y: auto; }
.gen-card .gc-body .line { display: block; }
.gen-card .gc-body .line b { color: #d96a00; }
.gc-foot { display: flex; gap: 8px; margin-top: 10px; }
.gc-btn {
flex: 1; border: 1px solid #ff7a1a; background: rgba(255,122,26,.06); color: #d96a00;
border-radius: 8px; padding: 7px 0; font-size: 12px; cursor: pointer; transition: all .2s; font-weight: 500;
}
.gc-btn:hover { background: #ff7a1a; color: #fff; }
.gc-btn.ghost { border-color: #e5e6eb; background: #fff; color: #4e5969; }
.gc-btn.ghost:hover { border-color: #ff7a1a; color: #d96a00; background: #fff; }
.tips-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.tip-pill { font-size: 11px; background: rgba(255,178,0,.08); color: #d97706; border-radius: 12px; padding: 3px 10px; }
.warn-pill { background: rgba(245,158,11,.1); color: #b45309; }
.ok-pill { background: rgba(34,197,94,.1); color: #15803d; }
.typing { display: inline-flex; gap: 4px; padding: 4px 2px; }
.typing span { width: 6px; height: 6px; background: #94a3b8; border-radius: 50%; animation: blink 1.2s infinite; }
.typing span:nth-child(2) { animation-delay: .2s; }
.typing span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
.ai-input-area { border-top: 1px solid #f2f3f5; padding: 12px 14px; flex-shrink: 0; background: #fff; }
.input-wrap { border: 1px solid #e5e6eb; border-radius: 12px; padding: 8px 8px 8px 12px; transition: border-color .2s, box-shadow .2s; }
.input-wrap:focus-within { border-color: #ff7a1a; box-shadow: 0 0 0 3px rgba(255,122,26,.1); }
.input-wrap textarea {
width: 100%; border: none; outline: none; resize: none; font-size: 13px; line-height: 1.6;
max-height: 90px; font-family: inherit; background: transparent;
}
.input-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
.input-tools { display: flex; gap: 4px; }
.tool-btn {
width: 28px; height: 28px; border: none; background: transparent; border-radius: 6px;
cursor: pointer; color: #86909c; font-size: 14px; transition: all .2s;
}
.tool-btn:hover { background: #f2f3f5; color: #d96a00; }
.send-btn {
background: linear-gradient(135deg, #ff7a1a, #ff6600); border: none; color: #fff;
width: 60px; height: 30px; border-radius: 8px; font-size: 13px; cursor: pointer;
transition: opacity .2s; font-weight: 500;
}
.send-btn:hover { opacity: .88; }
.input-hint { text-align: center; font-size: 11px; color: #c9cdd4; margin-top: 8px; }
.ai-float {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
width: 36px; height: 92px; background: linear-gradient(135deg, #ff7a1a, #ffb300);
border-radius: 10px 0 0 10px; color: #fff; border: none; cursor: pointer; font-size: 13px;
writing-mode: vertical-lr; letter-spacing: 3px; display: none; z-index: 20;
box-shadow: -4px 0 12px rgba(255,122,26,.25);
}
.ai-panel.collapsed .ai-float { display: block; }
.toast {
position: fixed; top: 68px; left: 50%; transform: translateX(-50%) translateY(-12px);
background: #1f2329; color: #fff; padding: 9px 20px; border-radius: 8px; font-size: 13px;
opacity: 0; pointer-events: none; transition: all .3s; z-index: 999;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #d5d9e0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #b8bec9; }
::-webkit-scrollbar-track { background: transparent; }
/* ============ 自主发品 Agent 卡片 ============ */
.agent-card {
border: 1px solid rgba(255, 122, 26, 0.45); border-radius: 12px; overflow: hidden;
background: linear-gradient(180deg, #fffaf3, #fff); margin-top: 4px;
}
.ag-head { background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; padding: 9px 12px; font-size: 13px; font-weight: 600; }
.ag-ctx { padding: 8px 12px; font-size: 12px; color: #d96a00; background: #fff7ed; border-bottom: 1px dashed rgba(255,122,26,.25); }
.ag-steps { padding: 4px 0; }
.ag-step { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 12.5px; color: #4e5969; border-bottom: 1px solid #f7f3ee; }
.ag-step:last-child { border-bottom: none; }
.ag-step .ag-ico { width: 18px; text-align: center; font-size: 13px; flex-shrink: 0; }
.ag-step .ag-lbl { flex: 1; }
.ag-step .ag-note { font-size: 11px; color: #86909c; }
.ag-step.todo .ag-note { color: #c0c4cc; }
.ag-step.doing { color: #d96a00; background: #fff8ef; }
.ag-step.doing .ag-ico { animation: agspin .8s linear infinite; display: inline-block; }
.ag-step.doing .ag-note { color: #d96a00; }
.ag-step.done .ag-lbl { color: #1f2329; }
.ag-step.done .ag-ico { color: #22c55e; }
@keyframes agspin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
.ag-foot { padding: 8px 12px; font-size: 11px; color: #86909c; background: #fafafa; border-top: 1px solid #f0f0f0; }
.ag-chip { display: inline-block; background: #fff; border: 1px solid rgba(255,122,26,.4); color: #ff7a1a; border-radius: 10px; padding: 1px 7px; font-size: 11px; margin: 0 3px 3px 0; }
/* ============ 串行确认栏 ============ */
.ag-confirm-bar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-top: 10px; padding: 9px 12px;
background: linear-gradient(135deg, #fff8ef, #fffaf3);
border: 1px solid rgba(255,122,26,.3); border-radius: 8px;
}
.ag-cf-btn {
background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; border: none;
padding: 7px 18px; border-radius: 6px; font-size: 12.5px; cursor: pointer;
font-weight: 600; font-family: inherit; transition: all .2s; white-space: nowrap;
}
.ag-cf-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(255,122,26,.35); }
.ag-regen-btn { padding: 7px 14px; font-size: 12px; border-radius: 6px; border: 1px solid #e5e6eb; background: #fff; color: #4e5969; cursor: pointer; font-family: inherit; transition: all .15s; white-space: nowrap; }
.ag-regen-btn:hover { border-color: #ff7a1a; color: #d96a00; }
.ag-cd { font-size: 11px; color: #d96a00; margin-left: auto; white-space: nowrap; }
.ag-cd.manual { color: #86909c; }
.ag-mode-row {
display: flex; align-items: center; gap: 6px;
padding: 7px 12px; background: #fff7ed; border-bottom: 1px solid rgba(255,122,26,.12);
}
.ag-mode-label { font-size: 11px; color: #86909c; }
.ag-mt-btn {
border: 1px solid rgba(255,122,26,.3); background: #fff; color: #d96a00;
padding: 3px 10px; border-radius: 10px; font-size: 11px; cursor: pointer;
font-family: inherit; transition: all .15s;
}
.ag-mt-btn.active { background: #ff7a1a; color: #fff; border-color: #ff7a1a; }
.serial-card .gc-foot { display: none; }
/* ============ 一键发品 ============ */
.btn-magic {
background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; border: none;
padding: 10px 26px; border-radius: 10px; font-size: 15px; cursor: pointer; font-weight: 600;
box-shadow: 0 4px 14px rgba(255,122,26,.35); transition: all .2s; font-family: inherit;
}
.btn-magic:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(255,122,26,.45); }
.btn-ghost-w {
border: 1px solid #e5e6eb; background: #fff; color: #4e5969; padding: 9px 22px;
border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; font-family: inherit;
}
.btn-ghost-w:hover { border-color: #ff7a1a; color: #d96a00; }
.modal-mask {
position: fixed; inset: 0; background: rgba(15,23,42,.45); backdrop-filter: blur(3px);
display: none; align-items: center; justify-content: center; z-index: 1000;
}
.modal-mask.show { display: flex; }
.modal {
width: 530px; max-height: 84vh; overflow-y: auto; background: #fff; border-radius: 16px;
padding: 24px; animation: pop .25s ease;
}
@keyframes pop { from { opacity: 0; transform: scale(.95) translateY(10px); } to { opacity: 1; transform: none; } }
.modal h3 { font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.modal .sub { font-size: 12px; color: #86909c; margin-top: 4px; }
.learn-hint {
margin-top: 14px; background: linear-gradient(135deg, rgba(255,178,0,.1), rgba(255,122,26,.08));
border: 1px solid #ffe3c2; border-radius: 10px; padding: 12px; font-size: 12px; line-height: 1.8; color: #4e5969;
}
.learn-hint b { color: #d96a00; }
.stat-row { display: flex; gap: 10px; margin-top: 14px; }
.stat-box { flex: 1; background: #f7f8fa; border-radius: 10px; padding: 10px; text-align: center; }
.stat-box .v { font-size: 18px; font-weight: 700; color: #d96a00; }
.stat-box .v small { font-size: 12px; font-weight: 400; color: #86909c; }
.stat-box .k { font-size: 11px; color: #86909c; margin-top: 2px; }
.level-wrap { margin-top: 14px; }
.level-bar { height: 6px; background: #f2f3f5; border-radius: 3px; overflow: hidden; margin-top: 6px; }
.level-bar i { display: block; height: 100%; background: linear-gradient(90deg, #ffa200, #ff6600); border-radius: 3px; transition: width .6s; }
.level-text { font-size: 12px; color: #4e5969; display: flex; justify-content: space-between; }
.conf-row { display: flex; gap: 12px; margin-top: 16px; }
.step-list { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; }
.step-item {
display: flex; align-items: center; gap: 10px; font-size: 13px; color: #86909c;
padding: 10px 12px; border: 1px solid #f2f3f5; border-radius: 10px; background: #fafbfc; transition: all .3s;
}
.step-item .s-icon {
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; background: #f2f3f5; color: #86909c; flex-shrink: 0;
}
.step-item.doing { border-color: #ffd9ae; background: rgba(255,178,0,.06); color: #1f2329; }
.step-item.doing .s-icon { background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.step-item.done { color: #1f2329; }
.step-item.done .s-icon { background: #22c55e; color: #fff; }
.step-item .s-note { margin-left: auto; font-size: 11px; color: #c9cdd4; }
.step-item.done .s-note { color: #15803d; }
.modal-foot { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.done-view { text-align: center; padding: 8px 0; }
.done-view .big { font-size: 42px; }
.done-view h4 { font-size: 16px; margin: 8px 0 4px; }
.done-view .dsub { font-size: 12px; color: #86909c; }
.oc-wide {
width: 100%; border: none; background: linear-gradient(135deg, #ffa200, #ff6600); color: #fff;
border-radius: 10px; padding: 10px; font-size: 13px; font-weight: 600; cursor: pointer;
margin-bottom: 10px; box-shadow: 0 3px 10px rgba(255,122,26,.25); transition: all .2s; font-family: inherit;
}
.oc-wide:hover { transform: translateY(-1px); box-shadow: 0 5px 14px rgba(255,122,26,.35); }
-291
View File
@@ -1,291 +0,0 @@
# CALM 设计对照:从 Rasa CALM 到本 Demo
> 本文说明本项目「商品发布 AI 助手(Copilot / Agent)」的**架构来源与设计取舍**。
> 核心参考范式为 **Rasa CALM**(Conversational AI with Language Models),
> 项目在意图理解层与流程执行层的分层方式,直接借鉴了 CALM 的四步架构。
---
## 1. 参考源
| 编号 | 来源 | 类型 | 链接 |
|---|---|---|---|
| R1 | Rasa CALM 原始论文(arXiv:2402.12234) | 一手 · 论文 | https://arxiv.org/pdf/2402.12234 |
| R2 | Rasa Pro 文档 · Dialogue Understanding | 一手 · 官方文档 | https://rasa.com/docs/rasa-pro/concepts/dialogue-understanding |
| R3 | Rasa Pro 文档 · Conversation Repair | 一手 · 官方文档 | https://rasa.com/docs/rasa-pro/concepts/conversation-repair |
| R4 | Rasa Studio 可视化流程编排 | 一手 · 官方文档 | https://rasa.com/docs/studio/user-guide/create-flow |
| R5 | 《大模型与任务型对话的结合:Rasa CALM》 | 二手 · 社区解读 | https://mp.weixin.qq.com/s/L_0IeGVeTml_N-Ypb6AwEQ |
| R6 | tod-in-context-learning 参考实现 | 一手 · 代码 | https://github.com/RasaHQ/tod-in-context-learning |
> 证据分级说明:R1–R4、R6 为论文/官方文档/官方代码仓库(一手);R5 为第三方技术解读(二手,用于快速建立术语认知,关键结论均回溯至 R1/R2 核对)。
---
## 2. CALM 要解决什么问题
### 2.1 传统任务型对话的 pipeline
业界标准做法是三段式:
```
语义理解(意图识别 + 词槽抽取)→ 对话管理(状态更新 + 策略选择)→ 话术生成
```
其内核是一条强假设:
```
f(对话状态) = 对话 action
```
而**对话状态的编码强依赖意图类别的识别**。一旦遇到预置意图之外、或本身就模糊的表达,整条链路就会退化成僵硬的分支判断——鲁棒性不足。
典型反例(来自 R5 的转述):
- 用户说「订餐厅」——可能是订中餐、订西餐,也可能只是查询,本身就难以归入单一类别;
- 用户说了一段很长的澄清文本——无法映射到预设类别体系里。
### 2.2 CALM 的主张
> **意图分类不是一件合理的事情**,因为人类表达意图可不仅仅是一个类别。
既然分类不合理,那就**取消意图分类**,让大模型直接把用户意图表达为**一组动作(Commands)**——即论文所述 *Commands as a Domain-Specific Language*。
---
## 3. CALM 的四步架构
```mermaid
flowchart TD
A["用户最新消息 + 对话历史"] --> B["① LLM 生成 Commands<br/>f(任务说明, 全部 flow 描述,<br/>当前 flow 与 slots, 对话历史) = commands"]
B --> C["② 校验 Commands 合理性<br/>对话管理引擎更新对话状态<br/>(当前 flow + slots)"]
C --> D["③ Conversation Repair<br/>判断是否需修复并更新状态"]
D --> E["④ 规则引擎按确定逻辑执行<br/>输出 Action 及对应话术"]
E --> F["表单/系统状态变更 + 回复用户"]
style B fill:#EEEDFE,stroke:#534AB7
style E fill:#E1F5EE,stroke:#0F6E56
```
**架构的关键判断:只有第 ① 步用大模型,其余三步都是确定逻辑。**
这一点决定了系统的工程性质:大模型负责它擅长的「理解」,规则引擎负责它擅长的「确定执行」。可解释性、可测试性、可兜底性都建立在这条边界上。
### 3.1 Commands DSL 清单
| Command | 语义 | 示例 |
|---|---|---|
| `StartFlow(flow_name)` | 启动一个业务流程 | `StartFlow(transfer_money)` |
| `SetSlot(slot, value)` | 填充槽位 | `SetSlot(recipient, John)` |
| `CancelFlow()` | 取消当前流程 | 用户说「算了」「stop that」 |
| `Clarify(flow_1, flow_2, ...)` | 歧义澄清 | 用户只说「contacts」→ `Clarify(list, add, remove)` |
| `SkipQuestion()` | 跳过当前追问 | 用户说「下一个问题」 |
| `SearchAndReply()` | 走知识库作答 | 非任务型知识问询 |
| `ChitChat()` | 闲聊 | 无其他可执行动作时兜底 |
| `HumanHandoff()` | 转人工 | 用户表达不满或明确要求 |
**关键设计**:`SetSlot` 中的值不再是通用实体(如「John」被识别为 person、45 被识别为 number),而是**直接用 flow 定义中的槽位语义来承接**——理解与流程定义在同一套语汇下对齐,消除了 NLU 中间表示的语义损耗。
### 3.2 Flow 的定义形态
flow = `description` + `steps`,其中 `steps` 只允许若干枚举类型(`action` / `collect` / `set_slots` / `noop`),并支持 `next` 分支与 `if` 条件:
```yaml
flows:
transfer_money:
description: This flow lets users send money to friends and family.
steps:
- collect: transfer_money_recipient
description: the name of a person
- id: "ask_amount"
collect: transfer_money_amount_of_money
- action: check_transfer_funds
next:
- if: not slots.transfer_money_has_sufficient_funds
then:
- action: utter_transfer_money_insufficient_funds
next: "ask_amount"
- else: transfer_money_final_confirmation
```
配套三个配置文件:`domain.yml`(话术/action/slot 声明)、`config.yaml`(LLM 版本与超时)、`endpoints.yml`(服务地址)。
---
## 4. 本 Demo 的逐层对照
本 Demo 是**纯前端可交互原型**(无后端、不接真实 LLM),因此第 ① 步的「理解」以规则/关键词实现,但**分层结构与数据契约完全按 CALM 的形态设计**,替换为 LLM function calling 即可落地(见 §7.1)。
| # | CALM 概念 | 本 Demo 的实现 | 代码位置 |
|---|---|---|---|
| 1 | 意图不再分类,直接生成 Commands | 不走 intent 分类,直接产出「工具序列 + 槽位值」 | `app/src/lib/agent.js` |
| 2 | `SetSlot(slot, value)` | `parseBrief(text)` → `{ dest, days, type, theme }` | `agent.js:26` |
| 3 | `StartFlow(flow_name)` 序列 | `parseMultiTools(text)` → 命中的工具类型数组(保序去重) | `agent.js:52` |
| 4 | Commands DSL 的取值域 | `toolKeywords` 关键词表(8 工具)+ `DEST_MAP` / `THEME_MAP` / `TYPES` 值域映射 | `agent.js:11,19,23,40` |
| 5 | 全量流程触发 | `agentIntentRe` 命中 → 启动 8 步全量流程 | `agent.js:8` |
| 6 | `flows.yml`:flow 定义(description + steps) | `agentSteps`(8 步自主发品流程定义)/ `ocStepsDef`(6 步一键发品流程定义) | `data/genData.js:10,22` |
| 7 | `actions/*.py`:action 实现 | `buildGen(type, ctx)` → `{ pre, card, data }` 结构化生成 | `data/genData.js:54` |
| 8 | domain 中的 action / slot 声明 | `cardMap`(工具 → 表单卡片锚点)/ `actionLabel`(工具 → 动作名) | `data/genData.js:48,32` |
| 9 | 对话状态:当前 flow + slots | `form`(槽位)+ `generatedRef`(各工具最新产物)+ `serialRef`(当前等待点) | `store/useAppStore.js:42,63,64` |
| 10 | 规则引擎确定性执行 | `agentRun` / `multiToolRun` 的 `for` 循环 + 任务清单状态机 | `useAppStore.js:325,351` |
| 11 | slot 的 `ask_before_filling` | `serialConfirm`:生成卡片后 Promise 挂起,等 5s 自动倒计时或手动确认 | `useAppStore.js:204,236` |
| 12 | 重新收集槽位 / 分支重走 | `serialRegen`「换一版」:重新生成当前步骤卡片并重置倒计时 | `useAppStore.js:214` |
| 13 | Conversation Repair(偏题/打断) | `sendMessage` 开头:若存在等待中的步骤,先自动确认收口再处理新消息 | `useAppStore.js:383` |
| 14 | `ChitChat()` / `KnowledgeAnswer()` 兜底 | `CANNED` 关键词兜底 + 3 条上下文感知的闲聊回复 | `useAppStore.js:29,410` |
| 15 | 执行过程的可见性(flow steps 逐条执行) | 任务清单卡片:`todo → doing → done` 三态 + 每步完成备注 | `useAppStore.js:253,265`;`components/TaskCardView.jsx` |
| 16 | 最终确认(final confirmation) | 生成卡片的「⚡ 一键填入」与「🔄 换一版」双动作 | `components/GenCardView.jsx` |
### 4.1 一次完整交互的等价展开
以 CALM 的样例句式对照本 Demo 的实际行为:
```
用户:帮我发个海南亲子 6 天自由行
CALM 侧等价输出:
StartFlow(publish_product)
SetSlot(dest, 海南(三亚))
SetSlot(theme, 亲子家庭)
SetSlot(days, 6天5晚)
本 Demo 实际执行:
parseBrief() → { dest: '海南(三亚)', theme: '亲子家庭', days: '6天5晚' }
applyBriefToForm()→ 槽位回写左侧表单
agentIntentRe → 命中「发」→ 启动 8 步全量流程
agentRun() → 逐工具 buildGen → 生成卡片 → 等确认 → applyGen 回填
```
---
## 5. 采纳了什么
### 5.1 取消意图分类,直接生成动作(采纳)
本 Demo **不存在 intent 分类器**。用户的自然语言直接映射为「要启动哪些流程 + 各槽位取值」,与 CALM 的主张一致。
这条选择在本场景的价值尤其明显:商家说「帮我优化一下」,实际上可能是要标题、要详情、要主图——强行归入一个 intent 会立刻失真;而输出「工具序列」天然可容纳这种多目标表达(`parseMultiTools` 返回的就是一个数组)。
### 5.2 理解层与执行层分离,且只有理解层是「智能」的(采纳)
这是 CALM 最值得复用的工程判断,也是本 Demo 的核心架构约束:
- **理解层**(`lib/agent.js`):唯一需要「聪明」的地方,负责把自然语言转成结构化动作;
- **执行层**(`store/useAppStore.js` 的四条编排流):纯确定性循环,每一步都可解释、可中断、可测试;
- **定义层**(`data/genData.js`):流程与产物以声明式数据描述,与执行引擎解耦。
由此得到三个直接收益:
1. **可解释**:每一步「为什么调用这个工具」都能回溯到一次解析结果;
2. **可中断**:执行中任何时候都能插入确认、换版、切换自动/手动模式;
3. **可测试**:解析层与生成层可分别单测,无需端到端跑 UI。
### 5.3 human-in-the-loop 的槽位确认(采纳并强化)
CALM 在 flow 中通过 `ask_before_filling: true` 让槽位在填充前先向用户确认。本 Demo 把这个机制从「槽位级」提升到「模块级」:
- `serialConfirm` 让每个工具生成后先出卡片,用户确认才回填表单;
- 支持 `autoConfirmMode` 运行中随时切换(⚡自动 5s / ✋手动无限等);
- `serialRegen` 提供「换一版」——等价于 CALM 重新收集槽位后重走分支。
对 B 端商家场景而言,这个机制的必要性高于 C 端对话:**AI 生成的内容会直接进入商品详情页,落库前必须由人过一道眼。**
---
## 6. 简化了什么
| CALM 的做法 | 本 Demo 的简化 | 简化理由与代价 |
|---|---|---|
| 第 ① 步由 LLM 生成 Commands | 关键词表 + 正则解析(`toolKeywords` / `parseBrief`) | 纯前端 demo 不引入模型依赖;代价是泛化能力受限,仅覆盖演示话术。**替换点已预留**(见 §7.1) |
| flow 定义用 YAML 配置 + 支持 `next` 分支与 `if` 条件 | `agentSteps` / `ocStepsDef` 为硬编码声明式数组,线性执行 | 演示流程本身线性(8 步固定顺序);代价是新增流程或分支需改代码 |
| 对话状态收敛为一个 `dialogue_state` 对象 | 状态分散在 `form` / `generatedRef` / `serialRef` | 复用 React 单向数据流;代价是缺少统一的「当前 flow / 当前槽位」视图 |
| Commands 显式落成可审计的中间表示 | 解析结果直接驱动执行,无 `commands` 中间层 | 少一次数据转换;代价是失去命令级日志与回放能力(见 §7.2) |
---
## 7. 差异与演进方向
### 7.1 已被预留的替换点:意图理解接 LLM
`lib/agent.js` 的三个导出(`agentIntentRe` / `parseBrief` / `parseMultiTools`)构成一个**窄接口**:入参为自然语言,出参为「工具序列 + 槽位值」。
落地真实 LLM 时,只需把这三个函数替换为一次 function calling 调用,**执行层(`agentRun` / `multiToolRun` / `startOC`)零改动**:
```
自然语言
├─ 现状:关键词/正则解析(前端模拟)
└─ 目标:LLM function calling,按 CALM 的 Prompt 模板组织
f(全部 flow 描述, 当前 flow 与 slots, 对话历史, 用户消息) = commands
执行层:不变
```
CALM 的 Prompt 模板(R1/R2)可直接作为契约蓝本:把「可用流程及其描述与槽位」「当前 flow 与槽位取值」「可用动作枚举」「槽位填充约束」四段填进 system prompt,要求模型**先输出分析、再逐行输出动作**。
### 7.2 尚未覆盖的 CALM 能力(下一版清单)
| 缺口 | 现状行为 | 补齐后的目标行为 | 对应 Command |
|---|---|---|---|
| 歧义澄清 | 用户只说「帮我优化一下」→ 落到闲聊兜底 | 反问「您是要优化标题、详情还是配图?」 | `Clarify(title, intro, detail, ...)` |
| 显式取消 | 说「算了」→ 落到闲聊兜底 | 中止当前流程、回滚已生成步骤 | `CancelFlow()` |
| 跳过当前步骤 | 说「跳过这步」→ 落到闲聊兜底 | 跳过当前槽位收集,继续后续步骤 | `SkipQuestion()` |
| 转人工 | 无 | 输出转人工指令并携带上下文 | `HumanHandoff()` |
| 命令中间表示 | 解析结果直接执行 | 生成 `commands[]` → 校验 → 执行,可日志化、可回放、可单测 | — |
| 对话状态视图 | 状态散在三个 ref | 统一 `dialogueState = { activeFlow, currentStep, slots }` | — |
| 流程声明式配置 | 硬编码数组 | 外置 `flows.json`(description + steps + next 分支) | — |
其中**「命令中间表示」优先级最高**。它是 §5.2 三项收益(可解释/可中断/可测试)的落点:一旦 commands 成为显式数据结构,就可以做命令级单测、命令级日志、以及「同一段对话回放不同模型输出」的评估——这正是 CALM 把 Commands 称为 DSL 的用意。
---
## 8. 超出 CALM 的部分:偏好学习
CALM 的边界止于「理解 + 执行」,**不处理跨会话的用户习惯沉淀**。本 Demo 在此之上加了一层产品化增量:
| 机制 | 实现 | 代码位置 |
|---|---|---|
| 偏好沉淀 | 每次发品写入 5 条偏好条目(路线天数、人群文案风格、话术偏好、定价策略、促销习惯) | `lib/oc.js:28` |
| 跨会话持久化 | `localStorage`(键 `fliggy_oc`) | `lib/oc.js:8,16` |
| 成长等级 | Lv.1 通用模板(0–2 次)→ Lv.2 风格匹配(3–9 次)→ Lv.3 爆款复刻(10+ 次) | `lib/oc.js:21` |
| 下次预填 | 一键发品弹窗自动回填上次配置并提示风格沿用语 | `store/useAppStore.js:419` |
| 双通道沉淀 | 对话式自主发品与弹窗式一键发品共用同一套记忆 | `store/useAppStore.js:305` |
**定位说明**:CALM 解决「怎么把话说准、把事做对」;偏好学习解决「怎么越用越贴合这个商家」。二者正交——偏好作为上下文注入理解层的 Prompt,不改变执行层的确定性。
---
## 9. 对外表述口径
需要向他人解释本项目的设计来源时,建议采用如下表述(避免把 CALM 的作用夸大或缩小):
> 任务型对话的经典做法是把用户表达归入一个预设意图类别,再走状态机选动作。这个假设在商家场景下很容易失效——「帮我优化一下」可能 одновременно 指向标题、详情和主图。
>
> 我参考的是 Rasa CALM 的思路:**不做意图分类,让模型直接生成一组动作**(启动哪个流程、填哪些槽位),再交给确定性的流程引擎执行。这套架构里只有「理解」这一步是模型在做事,剩下的执行、确认、修复全是确定逻辑——这正好满足商家侧对**可解释、可中断、可兜底**的要求。
>
> 在这之上我做了一层 CALM 没有的东西:**商家使用习惯库**。每次发品沉淀偏好,下次自动预填并按商家风格生成,让产品「越用越好用」。
---
## 10. 落地对应关系(接真实 LLM / 后端 Agent Loop)
| 本 Demo(前端原型) | 生产环境对应物 |
|---|---|
| `lib/agent.js` 关键词解析 | LLM function calling,按 CALM Prompt 模板构造上下文 |
| `data/genData.js` `buildGen()` | 各工具的生成服务(Prompt 见 `Prompt提示词设计文档.md`) |
| `store/useAppStore.js` 编排流 | 后端 Agent Loop:命令生成 → 校验 → 状态更新 → 执行 → 回填 |
| `agentSteps` / `ocStepsDef` | 流程配置中心(flow 声明式定义,支持分支与版本) |
| `lib/oc.js` `localStorage` | 商家画像 / 习惯库服务,作为个性化上下文注入 Prompt |
| `serialConfirm` 前端确认 | 同构的落库前人工复核节点(保持前端一致交互) |
| 任务清单卡片 | 执行过程可观测性:每步工具调用、耗时、结果状态 |
---
## 附:术语对照
| CALM 术语 | 本项目术语 | 说明 |
|---|---|---|
| Command | 动作 / 指令 | 理解层的输出单元 |
| Flow | 工具流程 / 任务清单 | 如「8 步自主发品」「6 步一键发品」 |
| Flow steps | 流程步骤 | `agentSteps` 的每一项 |
| Slot | 表单字段 / 上下文槽位 | 目的地、天数、类型、人群 |
| Action | 工具(Tool) | 8 个生成工具 |
| Dialogue Understanding | 意图解析 | `parseBrief` / `parseMultiTools` |
| Conversation Repair | 打断收口 / 兜底回复 | `sendMessage` 的等待点收口与闲聊兜底 |
| `ask_before_filling` | 逐工具确认 | `serialConfirm` 卡片确认 |
-1698
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "ai-assistant-travel",
"private": true,
"version": "2.0.0",
"description": "旅行商品发布 AI 助手 Demo —— B端商家商品发布平台的 Copilot / Agent 交互原型(Vite + React)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
-22
View File
@@ -1,22 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
/**
* 工程结构说明:
* - 仓库根目录的 index.html 是 v1 单文件演示快照,原样保留(不入 Vite 构建)。
* - Vite + React 应用的根目录为 app/,入口为 app/index.html,
* 这样两者互不干扰,可对照演示「单文件原型 → 前端工程化」的演进过程。
*/
export default defineConfig({
root: fileURLToPath(new URL('./app', import.meta.url)),
plugins: [react()],
build: {
outDir: '../dist',
emptyOutDir: true
},
server: {
port: 5173,
open: false
}
})