优化代码,增加注释

This commit is contained in:
liuziting
2026-01-21 21:57:41 +08:00
parent 9408000399
commit 3bf3919f60
14 changed files with 2107 additions and 1353 deletions

166
README.md
View File

@@ -1 +1,165 @@
# HouseGPT # ThinkFlow AI
English | [中文](./README.zh-CN.md)
ThinkFlow AI is a lightweight, local-first idea-to-structure workspace. You type a core idea, and the app expands it into a navigable knowledge graph (modules/submodules). Each node supports follow-up expansion, deep-dive details, and optional image generation.
This repository currently uses the package name `housegpt` (historical) while the product name is **ThinkFlow AI**.
## Highlights
- **Idea expansion to graph**: generate a root node and expand into structured child nodes.
- **Context-aware follow-up**: expand a selected node with an additional requirement (follow-up input).
- **Deep dive**: generate detailed explanations for a node and show them inline.
- **Image generation (per node)**: generate/regenerate a visual for a node and preview it fullscreen.
- **Export to Markdown**: export the current graph as a Markdown outline (including deep-dive content).
- **Local-first settings**: API mode and endpoints are stored in `localStorage` (no backend needed).
- **Bilingual UI**: built-in English/Chinese UI with a one-click toggle.
## Tech Stack
- **Vue 3** (Composition API)
- **Vite 5**
- **TypeScript**
- **Tailwind CSS**
- **VueFlow** (`@vue-flow/core`, `background`, `controls`, `minimap`)
- **vue-i18n**
- **lucide-vue-next** (icons)
## Project Structure
Key files you will touch most often:
- `src/App.vue`: app shell, composes the layout and wires UI to business actions.
- `src/composables/useThinkFlow.ts`: core business logic (graph operations, API calls, persistence).
- `src/components/TopNav.vue`: top toolbar (layout, export, summary, settings, language toggle).
- `src/components/BottomBar.vue`: bottom input bar (core idea input + execute).
- `src/components/WindowNode.vue`: custom VueFlow node UI (expand/deep-dive/image/follow-up).
- `src/components/SettingsModal.vue`: API mode and custom endpoints configuration UI.
- `src/i18n/index.ts`: i18n bootstrap (default English; persists language in `localStorage`).
## How It Works (High-Level)
ThinkFlow AI is built around a single composable:
- `useThinkFlow` owns all reactive state for nodes/edges and UI flags.
- UI components are “thin”: they render and forward events (expand, deep-dive, image, export).
- The graph is rendered with VueFlow using a custom node type (`window`).
- Settings are stored in `localStorage`:
- language (`language`)
- API mode + chat/image endpoints/models/keys
## Getting Started
### Prerequisites
- Node.js **18+** (required by Vite 5)
- npm (or pnpm/yarn; examples use npm)
### Install
```bash
npm install
```
### Run Dev Server
```bash
npm run dev
```
Then open the URL printed by Vite (usually `http://localhost:5173`).
### Build
```bash
npm run build
```
### Preview Production Build
```bash
npm run preview
```
## Configuration
### Custom Mode (Bring Your Own Endpoint)
Open **Settings** in the UI:
- Switch to **Custom**
- Configure **Text Generation** (chat completion)
- `baseUrl` (POST endpoint)
- `model`
- `apiKey`
- Configure **Image Generation**
- `baseUrl` (POST endpoint)
- `model`
- `apiKey`
These fields are saved into `localStorage` automatically.
## Features Guide
### 1) Expand From a Core Idea
1. Type a core idea in the bottom input.
2. Press Enter or click **Execute**.
3. A root node appears and the app generates child nodes.
### 2) Expand a Specific Node (Follow-Up)
1. Click into a nodes follow-up input.
2. Type an additional requirement.
3. Press Enter to expand using the context path (root → current node).
### 3) Deep Dive a Node
Click **Deep Dive** on a node to generate a detailed explanation. The content will be stored on the node and can be reopened without re-requesting (unless you expand further).
### 4) Generate/Preview Node Image
- Click **IMG** to generate an image for a node.
- Click the image to open fullscreen preview.
- Use the regenerate action to request a new image.
### 5) Export Markdown
Use **Export** in the top toolbar:
- Root becomes the Markdown title
- Child nodes become an indented list
- Deep-dive text is exported as blockquote content under the node
## Third-Party Integrations
- **Microsoft Clarity**: included in `index.html` for analytics.
- **VueFlow plugins**:
- Background (Dots/Lines)
- Controls (zoom/fit)
- MiniMap (overview)
## Internationalization (i18n)
- Default locale: **English**
- Supported: `en`, `zh`
- Language choice is stored in `localStorage` key `language`
To add a new language:
1. Add a JSON file in `src/i18n/locales/`
2. Register it in `src/i18n/index.ts`
3. Add a toggle option in the UI (currently a simple EN/ZH switch)
## Common Troubleshooting
- **CORS / Failed to fetch**: if you use a custom endpoint, ensure it allows browser requests and supports proper CORS headers.
- **401/403**: verify the API key and `Authorization: Bearer ...` format.
- **429**: rate limited by the provider; retry later.
## Scripts
- `npm run dev`: start Vite dev server
- `npm run build`: production build
- `npm run preview`: preview the build output locally

168
README.zh-CN.md Normal file
View File

@@ -0,0 +1,168 @@
# ThinkFlow AI
[English](./README.md) | 中文
ThinkFlow AI 是一个轻量、偏本地化Local-first的“从想法到结构化图谱”的工作台输入一个核心想法应用会把它扩展为可浏览的知识图谱模块/子模块)。每个节点支持继续追问扩展、深挖详情,以及可选的配图生成。
说明:当前仓库的 npm 包名仍为 `housegpt`(历史原因),产品名称为 **ThinkFlow AI**
## 亮点功能
- **想法扩展为图谱**:生成根节点并扩展出结构化子节点。
- **上下文追问扩展**对选中节点追加需求follow-up 输入)并基于路径上下文扩展。
- **深挖详情**:为节点生成更详细的解释/补充内容并在节点内展开展示。
- **节点配图(可重试)**:为单个节点生成/重新生成图片,并支持全屏预览。
- **导出 Markdown**:将当前图谱导出为 Markdown 大纲(包含深挖内容)。
- **本地配置**API 模式与配置保存到 `localStorage`,无需额外后端。
- **中英双语**:内置英文/中文 UI一键切换。
## 技术栈
- **Vue 3**Composition API
- **Vite 5**
- **TypeScript**
- **Tailwind CSS**
- **VueFlow**`@vue-flow/core` + background/controls/minimap
- **vue-i18n**
- **lucide-vue-next**(图标)
## 项目结构
你最常关注的文件:
- `src/App.vue`:应用壳层,组合布局与事件转发。
- `src/composables/useThinkFlow.ts`:核心业务逻辑(节点/边操作、API 调用、持久化)。
- `src/components/TopNav.vue`:顶部工具栏(布局、导出、总结、设置、语言切换)。
- `src/components/BottomBar.vue`:底部输入条(核心想法输入 + 执行)。
- `src/components/WindowNode.vue`:自定义节点 UI扩展/深挖/配图/follow-up
- `src/components/SettingsModal.vue`API 模式与自定义端点配置 UI。
- `src/i18n/index.ts`i18n 初始化(默认英文;语言写入 `localStorage`)。
## 核心原理(高层)
应用围绕一个 composable 构建:
- `useThinkFlow` 统一持有:节点/边数据、UI 状态、网络请求、错误处理、导出能力。
- 组件尽量“轻”负责渲染与事件转发expand、deep-dive、image、export 等)。
- 画布由 VueFlow 渲染,节点使用自定义类型 `window`
- 配置持久化到 `localStorage`
- 语言:`language`
- API 模式与 chat/image 的 baseUrl/model/apiKey
## 快速开始
### 环境要求
- Node.js **18+**Vite 5 要求)
- npm也可以用 pnpm/yarn示例使用 npm
### 安装依赖
```bash
npm install
```
### 启动开发环境
```bash
npm run dev
```
打开 Vite 输出的地址(通常是 `http://localhost:5173`)。
### 构建
```bash
npm run build
```
### 预览构建产物
```bash
npm run preview
```
## 配置说明
### 自定义模式自带端点BYO Endpoint
在界面里打开 **Settings**
- 切换到 **Custom**
- 配置 **文本生成chat completion**
- `baseUrl`POST 接口地址)
- `model`
- `apiKey`
- 配置 **图片生成**
- `baseUrl`POST 接口地址)
- `model`
- `apiKey`
这些字段会自动保存到 `localStorage`
## 功能使用说明
### 1从核心想法生成图谱
1. 在底部输入框输入核心想法
2. 回车或点击 **Execute**
3. 生成根节点并自动扩展出子节点
### 2对某个节点继续扩展Follow-Up
1. 在节点的 follow-up 输入框填写“追加需求”
2. 回车触发扩展
3. 扩展时会携带“从根到当前节点”的上下文路径,保证更贴合当前分支
### 3深挖节点详情
点击节点上的 **Deep Dive**
- 生成更长的解释/拓展内容
- 内容会写回节点并可重复打开(不必每次都重新请求)
### 4节点配图与预览
- 点击节点上的 **IMG** 生成配图
- 点击图片打开全屏预览
- 支持在预览层或节点上进行重新生成
### 5导出 Markdown
顶部工具栏点击 **Export**
- 根节点作为一级标题
- 子节点按缩进列表输出
- 深挖内容作为引用块输出到对应节点下方
## 第三方集成与插件
- **Microsoft Clarity**:在 `index.html` 中引入,用于分析与统计。
- **VueFlow 插件**
- BackgroundDots/Lines
- Controls缩放/适配)
- MiniMap总览
## 国际化i18n
- 默认语言:**英文**
- 已支持:`en``zh`
- 语言选择写入 `localStorage``language`
新增语言建议步骤:
1.`src/i18n/locales/` 添加对应 JSON
2.`src/i18n/index.ts` 注册 messages
3. 在 UI 中扩展语言切换逻辑(目前为 EN/ZH 双向切换)
## 常见问题排查
- **CORS / Failed to fetch**:使用自定义端点时,确保支持浏览器跨域请求并正确返回 CORS 响应头。
- **401/403**:检查 API Key 是否正确,以及是否使用 `Authorization: Bearer ...`
- **429**:触发限流,稍后重试。
## Scripts
- `npm run dev`:启动开发服务器
- `npm run build`:构建生产包
- `npm run preview`:本地预览构建产物

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/**
* 底部输入条
* - 接收 v-modelmodelValue作为输入内容
* - 触发 expand 事件,由 App/useThinkFlow 执行“生成/扩展”
*/
// 图标:输入提示与执行态
import { RefreshCw, Terminal, Zap } from 'lucide-vue-next'
/**
* props
* - ti18n 翻译函数
* - modelValue输入框内容由 v-model 驱动)
* - isLoading是否正在生成用于禁用按钮并显示 loading 图标)
*/
const props = defineProps<{
t: any
modelValue: string
isLoading: boolean
}>()
/**
* 事件:
* - update:modelValue更新输入框内容
* - expand触发一次生成/扩展
*/
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'expand'): void
}>()
</script>
<template>
<div class="fixed bottom-6 md:bottom-12 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center gap-4 w-full max-w-2xl px-4 md:px-6">
<div class="flex items-center gap-2 md:gap-3 w-full">
<div class="flex-grow flex items-center gap-2 md:gap-4 bg-slate-50 border border-slate-200 rounded-xl md:rounded-2xl px-3 md:px-5 py-2 md:py-3 focus-within:bg-white focus-within:shadow-xl focus-within:shadow-slate-100 transition-all">
<Terminal class="w-4 h-4 md:w-5 h-5 text-slate-400 flex-shrink-0" />
<input
:value="props.modelValue"
:placeholder="props.t('nav.placeholder')"
class="flex-grow bg-transparent border-none outline-none text-xs md:text-sm font-bold text-slate-700 placeholder:text-slate-300 min-w-0"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keyup.enter="emit('expand')"
/>
<button
@click="emit('expand')"
:disabled="props.isLoading || !props.modelValue.trim()"
class="flex items-center gap-1.5 md:gap-2 px-3 md:px-4 py-2 md:py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg md:rounded-xl transition-all active:scale-95 disabled:opacity-20 disabled:grayscale disabled:cursor-not-allowed group/btn flex-shrink-0"
>
<span class="text-[9px] md:text-[10px] font-black tracking-widest uppercase">{{ props.t('nav.execute') }}</span>
<Zap v-if="!props.isLoading" class="w-3.5 h-3.5 md:w-4 h-4 text-orange-400 group-hover/btn:scale-110 transition-transform" />
<RefreshCw v-else class="w-3.5 h-3.5 md:w-4 h-4 animate-spin" />
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* 图片预览弹窗
* - 接收 url存在则显示
* - 点击遮罩或关闭按钮触发 close
*/
// 图标:关闭按钮
import { X } from 'lucide-vue-next'
/**
* props
* - url需要预览的图片地址null 表示关闭)
*/
const props = defineProps<{
url: string | null
}>()
/**
* close关闭预览
*/
const emit = defineEmits<{
(e: 'close'): void
}>()
</script>
<template>
<Transition name="fade">
<div v-if="props.url" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-10" @click="emit('close')">
<div class="relative max-w-full max-h-full rounded-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-300" @click.stop>
<button @click="emit('close')" class="absolute top-4 right-4 p-2 bg-black/50 hover:bg-black/70 text-white rounded-full transition-colors z-10">
<X class="w-5 h-5" />
</button>
<img :src="props.url" class="max-w-screen max-h-screen object-contain" />
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
/**
* 重置确认弹窗
* - 用于“新会话/清空画布”前的二次确认
*/
// 图标:旋转提示
import { RefreshCw } from 'lucide-vue-next'
/**
* props
* - show弹窗显示开关
* - ti18n 翻译函数
*/
const props = defineProps<{
show: boolean
t: any
}>()
/**
* 事件:
* - close取消/关闭
* - confirm确认重置由 App/useThinkFlow 执行真正清空)
*/
const emit = defineEmits<{
(e: 'close'): void
(e: 'confirm'): void
}>()
</script>
<template>
<Transition name="fade">
<div v-if="props.show" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="emit('close')"></div>
<div class="relative bg-white rounded-2xl shadow-2xl border border-slate-100 p-6 w-full max-w-sm overflow-hidden group animate-in zoom-in duration-300">
<div class="absolute -top-12 -right-12 w-24 h-24 bg-orange-50 rounded-full blur-2xl group-hover:bg-orange-100 transition-colors"></div>
<div class="relative flex flex-col items-center text-center space-y-4">
<div class="w-16 h-16 bg-orange-50 rounded-2xl flex items-center justify-center text-orange-500 mb-2 ring-4 ring-orange-50/50">
<RefreshCw class="w-8 h-8 animate-spin-slow" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-bold text-slate-800 tracking-tight">{{ props.t('nav.reset') }}</h3>
<p class="text-sm text-slate-500 leading-relaxed px-4">
{{ props.t('common.confirmReset') }}
</p>
</div>
<div class="flex items-center gap-3 w-full pt-2">
<button
@click="emit('close')"
class="flex-1 px-4 py-2.5 rounded-xl border border-slate-200 text-slate-600 font-medium hover:bg-slate-50 transition-colors active:scale-95"
>
{{ props.t('common.cancel') || 'Cancel' }}
</button>
<button
@click="emit('confirm')"
class="flex-1 px-4 py-2.5 rounded-xl bg-orange-500 text-white font-medium hover:bg-orange-600 shadow-lg shadow-orange-500/30 transition-all active:scale-95"
>
{{ props.t('common.confirm') || 'Confirm' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
/**
* 设置弹窗API 配置)
* - 默认模式:使用内置 DEFAULT_CONFIG + 环境变量 API Key无需输入
* - 自定义模式:允许分别配置“文本生成/图片生成”的 baseUrl、model、apiKey
* - 写入逻辑不在组件内apiConfig 是响应式对象,持久化由 useThinkFlow 的 watch 完成
*/
// 图标:分区与输入项标识
import { Activity, Image as ImageIcon, Key, Link as LinkIcon, Settings, Shield, Sparkles, X } from 'lucide-vue-next'
/**
* props
* - show弹窗显示开关
* - ti18n 翻译函数
* - apiConfig由 useThinkFlow 提供的响应式配置对象
*/
const props = defineProps<{
show: boolean
t: any
apiConfig: any
}>()
/**
* close关闭弹窗保存按钮也仅触发关闭真实保存由响应式 + watch 完成)
*/
const emit = defineEmits<{
(e: 'close'): void
}>()
</script>
<template>
<div v-if="props.show" class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" @click.self="emit('close')">
<div class="bg-white rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-slate-200 animate-in fade-in zoom-in duration-300">
<div class="px-8 py-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div class="flex items-center gap-3">
<div class="p-2 bg-slate-900 rounded-xl text-white">
<Settings class="w-5 h-5" />
</div>
<div>
<h3 class="text-lg font-black text-slate-900 tracking-tight">{{ props.t('settings.title') }}</h3>
<p class="text-xs text-slate-500 font-bold uppercase tracking-wider">{{ props.t('settings.subtitle') }}</p>
</div>
</div>
<button @click="emit('close')" class="p-2 hover:bg-slate-200 rounded-xl transition-colors">
<X class="w-5 h-5 text-slate-500" />
</button>
</div>
<div class="p-8 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div class="flex p-1.5 bg-slate-100 rounded-2xl w-fit">
<button
@click="props.apiConfig.mode = 'default'"
class="px-6 py-2 rounded-xl text-xs font-black tracking-widest transition-all"
:class="props.apiConfig.mode === 'default' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
{{ props.t('common.default') }}
</button>
<button
@click="props.apiConfig.mode = 'custom'"
class="px-6 py-2 rounded-xl text-xs font-black tracking-widest transition-all"
:class="props.apiConfig.mode === 'custom' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
{{ props.t('common.custom') }}
</button>
</div>
<div v-if="props.apiConfig.mode === 'custom'" class="space-y-4 animate-in slide-in-from-top-2 duration-300">
<div class="flex items-center gap-2 text-slate-900">
<Sparkles class="w-4 h-4 text-orange-500" />
<span class="text-sm font-black uppercase tracking-widest">{{ props.t('settings.textGen') }}</span>
</div>
<div class="grid grid-cols-1 gap-4 p-5 bg-slate-50 rounded-2xl border border-slate-100">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<LinkIcon class="w-3 h-3" /> {{ props.t('settings.baseUrl') }}
</label>
<input
v-model="props.apiConfig.chat.baseUrl"
type="text"
:placeholder="props.t('settings.placeholderUrl')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Shield class="w-3 h-3" /> {{ props.t('settings.modelName') }}
</label>
<input
v-model="props.apiConfig.chat.model"
type="text"
:placeholder="props.t('settings.placeholderModel')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Key class="w-3 h-3" /> {{ props.t('settings.apiKey') }}
</label>
<input
v-model="props.apiConfig.chat.apiKey"
type="password"
:placeholder="props.t('settings.placeholderKey')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
<div v-if="props.apiConfig.mode === 'custom'" class="space-y-4 animate-in slide-in-from-top-2 duration-300">
<div class="flex items-center gap-2 text-slate-900">
<ImageIcon class="w-4 h-4 text-blue-500" />
<span class="text-sm font-black uppercase tracking-widest">{{ props.t('settings.imageGen') }}</span>
</div>
<div class="grid grid-cols-1 gap-4 p-5 bg-slate-50 rounded-2xl border border-slate-100">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<LinkIcon class="w-3 h-3" /> {{ props.t('settings.baseUrl') }}
</label>
<input
v-model="props.apiConfig.image.baseUrl"
type="text"
:placeholder="props.t('settings.placeholderUrl')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Shield class="w-3 h-3" /> {{ props.t('settings.modelName') }}
</label>
<input
v-model="props.apiConfig.image.model"
type="text"
:placeholder="props.t('settings.placeholderModel')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-1">
<Key class="w-3 h-3" /> {{ props.t('settings.apiKey') }}
</label>
<input
v-model="props.apiConfig.image.apiKey"
type="password"
:placeholder="props.t('settings.placeholderKey')"
class="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono"
/>
</div>
</div>
</div>
</div>
<div v-if="props.apiConfig.mode === 'default'" class="flex flex-col items-center justify-center py-12 text-center space-y-4 animate-in fade-in duration-500">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center">
<Activity class="w-8 h-8 text-slate-400" />
</div>
<div class="space-y-1">
<h4 class="text-sm font-black text-slate-900 uppercase">{{ props.t('settings.defaultModeTitle') }}</h4>
<p class="text-xs text-slate-500 max-w-[280px]">{{ props.t('settings.defaultModeDesc') }}</p>
</div>
</div>
</div>
<div class="px-8 py-6 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
<button
@click="emit('close')"
class="px-8 py-2.5 bg-slate-900 text-white rounded-xl text-xs font-black tracking-widest hover:bg-slate-800 transition-all shadow-lg shadow-slate-200 active:scale-95"
>
{{ props.t('common.save') }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* 总结弹窗
* - show 控制显示
* - isSummarizing 为 true 时展示 loading
* - summaryContent 展示 AI 总结结果
*/
// 图标:标题/关闭/加载
import { RefreshCw, Sparkles, X } from 'lucide-vue-next'
/**
* props
* - show弹窗显示开关
* - ti18n 翻译函数
* - isSummarizing是否正在生成总结
* - summaryContent总结文本内容
*/
const props = defineProps<{
show: boolean
t: any
isSummarizing: boolean
summaryContent: string
}>()
/**
* close关闭弹窗
*/
const emit = defineEmits<{
(e: 'close'): void
}>()
</script>
<template>
<Transition name="fade">
<div v-if="props.show" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="emit('close')"></div>
<div class="relative bg-white rounded-3xl shadow-2xl border border-slate-100 w-full max-w-2xl overflow-hidden animate-in zoom-in duration-300">
<div class="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-orange-400 to-rose-400"></div>
<div class="p-6 md:p-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-orange-50 rounded-xl flex items-center justify-center text-orange-500">
<Sparkles class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black text-slate-800 tracking-tight">{{ props.t('common.summaryTitle') }}</h3>
<p class="text-xs text-slate-400 font-medium uppercase tracking-wider">{{ props.t('common.aiGenerated') }}</p>
</div>
</div>
<button @click="emit('close')" class="p-2 hover:bg-slate-50 rounded-full text-slate-400 transition-colors">
<X class="w-5 h-5" />
</button>
</div>
<div class="relative min-h-[200px] max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
<div v-if="props.isSummarizing" class="absolute inset-0 flex flex-col items-center justify-center space-y-4">
<RefreshCw class="w-8 h-8 text-orange-500 animate-spin" />
<p class="text-sm font-bold text-slate-400 animate-pulse">{{ props.t('common.summarizing') }}</p>
</div>
<div v-else class="prose prose-slate max-w-none">
<div class="whitespace-pre-wrap text-slate-600 leading-relaxed text-sm md:text-base font-medium">
{{ props.summaryContent }}
</div>
</div>
</div>
<div class="mt-8 flex justify-end">
<button
@click="emit('close')"
class="px-8 py-3 bg-slate-900 text-white rounded-2xl text-xs font-black tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 active:scale-95"
>
{{ props.t('common.close') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>

238
src/components/TopNav.vue Normal file
View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
/**
* 顶部导航栏
* - 桌面端:展示完整工具条(视图控制/布局/配色/导出/设置等)
* - 移动端:收纳为下拉面板,通过 callAndClose 统一“执行 + 收起”
*/
// 基础依赖
import { ref } from 'vue'
// 背景样式枚举(用于切换 Lines/Dots
import { BackgroundVariant } from '@vue-flow/background'
// 图标:所有按钮与状态展示
import {
ChevronDown,
ChevronUp,
Download,
Focus,
Globe,
LayoutDashboard,
Map,
Menu,
Palette,
Settings,
Sparkles,
Target,
Trash2,
X
} from 'lucide-vue-next'
/**
* props
* - ti18n 翻译函数
* - locale当前语言标识用于显示 EN/ZH
* - config全局画布配置边颜色/背景/小地图开关等)
* - onXxx由 App 传入的动作回调
*/
const props = defineProps<{
t: any
locale: string
config: any
onFit: () => void
onResetLayout: () => void
onCenterRoot: () => void
onStartNewSession: () => void
onGenerateSummary: () => void
onExportMarkdown: () => void
onOpenSettings: () => void
}>()
const emit = defineEmits<{
(e: 'toggle-locale'): void
}>()
/**
* 移动端工具面板是否展开
*/
const isToolsExpanded = ref(false)
/**
* 执行某个工具动作后自动收起移动端面板
*/
const callAndClose = (fn: () => void) => {
fn()
isToolsExpanded.value = false
}
</script>
<template>
<nav class="flex-none bg-white/80 backdrop-blur-md border-b border-slate-200 px-3 md:px-6 py-2 md:py-3 flex items-center justify-between shadow-sm z-50">
<div class="flex items-center gap-2 md:gap-6 flex-grow mr-2">
<div class="flex items-center gap-2 flex-shrink-0">
<div class="w-3 h-3 bg-orange-500 rounded-sm rotate-45"></div>
<span class="font-black text-slate-900 tracking-tighter text-base md:text-lg">ThinkFlow</span>
</div>
<div class="h-6 w-[1px] bg-slate-200 mx-1 md:mx-2 flex-shrink-0"></div>
<div class="hidden md:flex items-center gap-2">
<button @click="props.onFit" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100 flex-shrink-0" :title="props.t('nav.fit')">
<Focus class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.fit') }}</span>
</button>
<button @click="props.onResetLayout" class="toolbar-btn text-purple-500 hover:bg-purple-50 border-purple-100 flex-shrink-0" :title="props.t('nav.layout')">
<LayoutDashboard class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.layout') }}</span>
</button>
<button @click="props.onCenterRoot" class="toolbar-btn text-orange-500 hover:bg-orange-50 border-orange-100 flex-shrink-0" :title="props.t('nav.center')">
<Target class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.center') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onStartNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100 flex-shrink-0" :title="props.t('nav.reset')">
<Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.reset') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<div class="flex items-center gap-2 px-2 md:px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100 flex-shrink-0">
<Palette class="w-3 h-3 md:w-3.5 h-3.5 text-slate-400" />
<input type="color" v-model="props.config.edgeColor" class="w-3.5 h-3.5 md:w-4 h-4 rounded cursor-pointer bg-transparent border-none" />
<span class="text-[9px] md:text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.backgroundVariant" class="toolbar-select flex-shrink-0">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
</select>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button
@click="props.config.showMiniMap = !props.config.showMiniMap"
class="toolbar-btn border-slate-100 flex-shrink-0"
:class="props.config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.map')"
>
<Map class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.map') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onGenerateSummary" class="toolbar-btn text-orange-600 hover:bg-orange-50 border-orange-100 flex-shrink-0" :title="props.t('nav.summary')">
<Sparkles class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.summary') }}</span>
</button>
<button @click="props.onExportMarkdown" class="toolbar-btn text-indigo-600 hover:bg-indigo-50 border-indigo-100 flex-shrink-0" :title="props.t('nav.export')">
<Download class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.export') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onOpenSettings" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100 flex-shrink-0">
<Settings class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('common.settings') }}</span>
</button>
</div>
<div class="md:hidden flex items-center">
<button
@click="isToolsExpanded = !isToolsExpanded"
class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-slate-600 active:bg-slate-100 transition-colors"
>
<Menu v-if="!isToolsExpanded" class="w-4 h-4" />
<X v-else class="w-4 h-4" />
<span class="text-xs font-bold">{{ props.t('common.tools') || 'Tools' }}</span>
<ChevronDown v-if="!isToolsExpanded" class="w-3 h-3 opacity-50" />
<ChevronUp v-else class="w-3 h-3 opacity-50" />
</button>
</div>
</div>
<div class="flex items-center gap-2 md:gap-3 flex-shrink-0">
<button
@click="emit('toggle-locale')"
class="p-1.5 md:p-2 hover:bg-slate-100 rounded-md transition-colors text-slate-400 font-bold text-[10px] md:text-xs flex items-center gap-1"
>
<Globe class="w-3 h-3 md:w-3.5 h-3.5" /> {{ props.locale === 'zh' ? 'EN' : 'ZH' }}
</button>
</div>
</nav>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0"
>
<div v-if="isToolsExpanded" class="md:hidden absolute top-[57px] left-0 right-0 bg-white/95 backdrop-blur-md border-b border-slate-200 shadow-xl z-40 py-4 px-4 flex flex-wrap gap-3 justify-center">
<button @click="callAndClose(props.onFit)" class="toolbar-btn text-blue-500 hover:bg-blue-50 border-blue-100" :title="props.t('nav.fit')">
<Focus class="w-4 h-4" />
<span>{{ props.t('nav.fit') }}</span>
</button>
<button @click="callAndClose(props.onResetLayout)" class="toolbar-btn text-purple-500 hover:bg-purple-50 border-purple-100" :title="props.t('nav.layout')">
<LayoutDashboard class="w-4 h-4" />
<span>{{ props.t('nav.layout') }}</span>
</button>
<button @click="callAndClose(props.onCenterRoot)" class="toolbar-btn text-orange-500 hover:bg-orange-50 border-orange-100" :title="props.t('nav.center')">
<Target class="w-4 h-4" />
<span>{{ props.t('nav.center') }}</span>
</button>
<button @click="callAndClose(props.onStartNewSession)" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100" :title="props.t('nav.reset')">
<Trash2 class="w-4 h-4" />
<span>{{ props.t('nav.reset') }}</span>
</button>
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-100">
<Palette class="w-4 h-4 text-slate-400" />
<input type="color" v-model="props.config.edgeColor" class="w-4 h-4 rounded cursor-pointer bg-transparent border-none" />
<span class="text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.backgroundVariant" class="toolbar-select">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
</select>
<button
@click="props.config.showMiniMap = !props.config.showMiniMap"
class="toolbar-btn border-slate-100"
:class="props.config.showMiniMap ? 'text-blue-500 bg-blue-50 border-blue-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.map')"
>
<Map class="w-4 h-4" />
<span>{{ props.t('nav.map') }}</span>
</button>
<button @click="callAndClose(props.onGenerateSummary)" class="toolbar-btn text-orange-600 hover:bg-orange-50 border-orange-100" :title="props.t('nav.summary')">
<Sparkles class="w-4 h-4" />
<span>{{ props.t('nav.summary') }}</span>
</button>
<button @click="callAndClose(props.onExportMarkdown)" class="toolbar-btn text-indigo-600 hover:bg-indigo-50 border-indigo-100" :title="props.t('nav.export')">
<Download class="w-4 h-4" />
<span>{{ props.t('nav.export') }}</span>
</button>
<button @click="callAndClose(props.onOpenSettings)" class="toolbar-btn text-slate-600 hover:bg-slate-50 border-slate-100">
<Settings class="w-4 h-4" />
<span>{{ props.t('common.settings') }}</span>
</button>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
/**
* 自定义节点WindowNode
* - 展示节点标题、描述、错误态、图片、深挖内容、followUp 输入
* - 将用户交互(深挖/配图/继续扩展)转发给 useThinkFlow 的动作函数
* - 使用 activePath 对非路径节点做弱化处理,突出当前上下文
*/
// 组件状态
import { ref } from 'vue'
// VueFlow连接点
import { Handle, Position } from '@vue-flow/core'
// 图标:节点 UI
import {
ArrowRight,
BookOpen,
ChevronRight,
Image as ImageIcon,
Maximize2,
RefreshCw,
Shield,
Terminal,
X
} from 'lucide-vue-next'
/**
* props
* - id/data/selectedVueFlow 提供的节点数据
* - t/config全局翻译与样式配置
* - activeNodeId/activePath用于路径高亮与节点弱化
* - flowNodes/updateNode/deepDive/generateNodeImage/expandIdea由 App/useThinkFlow 传入的能力
*/
const props = defineProps<{
id: string
data: any
selected: boolean
t: any
config: any
activeNodeId: string | null
activePath: { nodeIds: Set<string>; edgeIds: Set<string> }
flowNodes: any[]
updateNode: (id: string, payload: any) => void
deepDive: (id: string, topic: string) => void
generateNodeImage: (id: string, prompt: string) => void
expandIdea: (param?: any, customInput?: string) => void
}>()
/**
* preview请求 App 打开图片预览弹窗
*/
const emit = defineEmits<{
(e: 'preview', url: string): void
}>()
/**
* followUp 输入框是否聚焦(用于边框高亮)
*/
const isFocused = ref(false)
/**
* 从 flowNodes 中找到节点当前位置,用于扩展时定位新节点生成的参考坐标
*/
const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.position
</script>
<template>
<div
class="window-node group transition-all duration-500"
:class="{
'opacity-40 grayscale-[0.4] blur-[0.5px] scale-[0.98] pointer-events-none': props.activeNodeId && !props.activePath.nodeIds.has(props.id),
'opacity-100 grayscale-0 blur-0 scale-105 z-50 ring-2 ring-offset-4': props.activePath.nodeIds.has(props.id),
'!w-[450px]': props.data.isDetailExpanded
}"
:style="{
borderColor: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : props.config.edgeColor + '40',
boxShadow: props.activeNodeId === props.id ? `0 20px 50px -12px ${props.config.edgeColor}40` : '',
'--tw-ring-color': props.selected ? props.config.edgeColor + '40' : 'transparent'
}"
>
<Handle type="target" :position="Position.Left" class="!bg-transparent !border-none" />
<Handle type="source" :position="Position.Right" class="!bg-transparent !border-none" />
<div class="window-header" :style="{ backgroundColor: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor + '15' : props.config.edgeColor + '05' }">
<div class="flex gap-1.5">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : props.config.edgeColor + '40' }"></div>
<div class="w-2 h-2 rounded-full bg-slate-200"></div>
<div class="w-2 h-2 rounded-full bg-slate-200"></div>
</div>
<span class="window-title" :style="{ color: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : '' }">
{{ props.data.type === 'root' ? props.t('node.mainTitle') : props.t('node.moduleTitle') }}
</span>
</div>
<div v-if="props.data.isExpanding" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/60 backdrop-blur-[2px] rounded-2xl transition-all duration-300">
<div class="relative">
<RefreshCw class="w-8 h-8 text-slate-900 animate-spin mb-3" :style="{ color: props.config.edgeColor }" />
<div class="absolute inset-0 blur-xl opacity-20 animate-pulse" :style="{ backgroundColor: props.config.edgeColor }"></div>
</div>
<span class="text-[10px] font-black tracking-widest uppercase text-slate-500">{{ props.t('common.expanding') }}</span>
</div>
<div class="window-content">
<div
v-if="props.data.imageUrl || props.data.isImageLoading"
class="mb-4 rounded-lg overflow-hidden bg-slate-50 border border-slate-100 aspect-video flex items-center justify-center relative group/img cursor-pointer"
@click.stop="props.data.imageUrl ? emit('preview', props.data.imageUrl) : null"
>
<img v-if="props.data.imageUrl" :src="props.data.imageUrl" class="w-full h-full object-cover" />
<div v-if="props.data.isImageLoading" class="absolute inset-0 flex flex-col items-center justify-center bg-white/80 backdrop-blur-sm cursor-default">
<RefreshCw class="w-6 h-6 text-orange-500 animate-spin mb-2" />
<span class="text-[8px] font-bold text-slate-400 uppercase">{{ props.t('common.generating') }}</span>
</div>
<div v-if="props.data.imageUrl" class="absolute inset-0 bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button class="p-2 bg-white/20 hover:bg-white/40 rounded-full backdrop-blur-md transition-all" :title="props.t('node.view')">
<Maximize2 class="w-4 h-4 text-white" />
</button>
<button
@click.stop="props.generateNodeImage(props.id, props.data.label)"
class="p-2 bg-white/20 hover:bg-white/40 rounded-full backdrop-blur-md transition-all"
:title="props.t('node.regenerate')"
>
<RefreshCw class="w-4 h-4 text-white" />
</button>
</div>
</div>
<div class="flex items-start gap-2 mb-2">
<span class="font-bold shrink-0 mt-0.5" :style="{ color: props.config.edgeColor }">></span>
<h3
class="font-black text-slate-900 tracking-tight cursor-pointer hover:text-orange-600 transition-colors"
:class="props.data.isTitleExpanded ? 'whitespace-normal' : 'truncate'"
@click.stop="props.updateNode(props.id, { data: { ...props.data, isTitleExpanded: !props.data.isTitleExpanded } })"
>
{{ props.data.label }}
</h3>
</div>
<p class="text-[10px] text-slate-500 leading-relaxed font-medium line-clamp-3">
{{ props.data.description }}
</p>
<div v-if="props.data.error" class="mt-3 p-2.5 bg-red-50 border border-red-100 rounded-lg animate-in fade-in slide-in-from-top-1 duration-300">
<div class="flex items-start gap-2">
<Shield class="w-3.5 h-3.5 text-red-500 shrink-0 mt-0.5" />
<div class="flex-grow space-y-1">
<p class="text-[10px] font-black text-red-600 leading-tight">{{ props.t('common.error.title') }}</p>
<p class="text-[9px] text-red-500 leading-relaxed">{{ props.data.error }}</p>
</div>
<button
@click.stop="props.data.imageUrl === null && props.data.isImageLoading === false ? props.generateNodeImage(props.id, props.data.label) : props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) })"
class="p-1 hover:bg-red-100 rounded transition-colors"
:title="props.t('common.error.retry')"
>
<RefreshCw class="w-3 h-3 text-red-600" />
</button>
</div>
</div>
<div class="pt-3 mt-3 border-t border-slate-50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5 shrink-0">
<div class="w-1.5 h-1.5 rounded-full animate-pulse" :style="{ backgroundColor: props.data.isExpanding ? props.config.edgeColor : '#34d399' }"></div>
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{{
props.data.isExpanding ? props.t('common.expanding') : props.t('common.active')
}}</span>
</div>
<div class="flex items-center gap-2">
<button
@click.stop="props.deepDive(props.id, props.data.label)"
class="action-btn text-orange-500 hover:bg-orange-50"
:title="props.t('node.deepDive')"
>
<BookOpen class="w-2.5 h-2.5" />
<span>{{ props.t('node.deepDive') }}</span>
</button>
<button
v-if="!props.data.imageUrl && !props.data.isImageLoading"
@click.stop="props.generateNodeImage(props.id, props.data.label)"
class="action-btn text-blue-500 hover:bg-blue-50"
>
<ImageIcon class="w-2.5 h-2.5" />
<span>{{ props.t('node.imgAction') }}</span>
</button>
</div>
</div>
<div v-if="props.data.isDetailExpanded" class="mb-4 pt-4 border-t border-slate-100 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="flex items-center justify-between mb-2">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ props.t('node.deepDive') }}</span>
<button @click.stop="props.updateNode(props.id, { data: { ...props.data, isDetailExpanded: false } })" class="text-slate-300 hover:text-slate-500">
<X class="w-3 h-3" />
</button>
</div>
<div v-if="props.data.isDeepDiving" class="flex flex-col items-center py-6">
<div class="relative mb-3">
<RefreshCw class="w-6 h-6 text-orange-400 animate-spin" />
<div class="absolute inset-0 blur-lg bg-orange-200 opacity-50 animate-pulse"></div>
</div>
<span class="text-[9px] font-black text-slate-300 uppercase tracking-widest animate-pulse">{{ props.t('common.loading') }}</span>
</div>
<div v-else class="text-[11px] text-slate-600 leading-relaxed font-medium whitespace-pre-wrap max-h-[350px] overflow-y-auto custom-scrollbar pr-2 selection:bg-orange-100 nowheel">
{{ props.data.detailedContent }}
</div>
</div>
<div class="relative group/input">
<div
class="flex items-center gap-2 bg-slate-50 rounded-lg px-2.5 py-2 border border-slate-100 focus-within:bg-white transition-all"
:style="{ borderColor: props.data.followUp || isFocused ? props.config.edgeColor : '' }"
>
<ChevronRight v-if="!props.data.followUp" class="w-3 h-3 text-slate-400" />
<Terminal v-else class="w-3 h-3" :style="{ color: props.config.edgeColor }" />
<input
v-model="props.data.followUp"
@focus="isFocused = true"
@blur="isFocused = false"
@keyup.enter="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
:placeholder="props.t('node.followUp')"
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-300"
:disabled="props.data.isExpanding"
/>
<button
@click.stop="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
:disabled="!props.data.followUp?.trim() || props.data.isExpanding"
class="transition-all transform active:scale-90"
:style="{ color: props.data.followUp?.trim() ? props.config.edgeColor : '#cbd5e1' }"
>
<RefreshCw v-if="props.data.isExpanding" class="w-3.5 h-3.5 animate-spin" />
<ArrowRight v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,755 @@
/**
* ThinkFlow 核心业务 composable
* - 统一管理:画布状态(节点/边、交互状态、API 调用、错误处理与导出能力
* - 对外提供页面与组件可直接调用的状态与动作expand / deepDive / image / summary 等)
*/
import { computed, reactive, ref, watch, type Ref } from 'vue'
import { MarkerType, Position, useVueFlow } from '@vue-flow/core'
import { BackgroundVariant } from '@vue-flow/background'
/**
* i18n 翻译函数类型(等价于 vue-i18n 的 t
*/
type Translate = (key: string, params?: any) => string
/**
* 创建 ThinkFlow 的业务上下文。
* @param t 国际化翻译函数
* @param locale 当前语言(用于持久化语言选择)
*/
export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string> }) {
/**
* 默认模式下的 API Key当前不使用环境变量注入
* - 如需鉴权,请通过 Settings 的 Custom 模式填写 apiKey
*/
const API_KEY = ''
/**
* API 配置(支持默认/自定义两种模式)
* - 自定义模式写入 localStorage刷新后仍保留
*/
const apiConfig = reactive({
mode: localStorage.getItem('api_mode') || 'default',
chat: {
baseUrl: localStorage.getItem('chat_baseUrl') || '',
model: localStorage.getItem('chat_model') || '',
apiKey: localStorage.getItem('chat_apiKey') || ''
},
image: {
baseUrl: localStorage.getItem('image_baseUrl') || '',
model: localStorage.getItem('image_model') || '',
apiKey: localStorage.getItem('image_apiKey') || ''
}
})
/**
* 默认接口配置(当用户选择默认模式时使用)
* - apiKey 允许为空:会回退到 API_KEY环境变量
*/
const DEFAULT_CONFIG = {
chat: {
baseUrl: 'https://thinkflow.lz-t.top/chat/completions',
model: 'glm-4-flash',
apiKey: ''
},
image: {
baseUrl: 'https://thinkflow.lz-t.top/images/generations',
model: 'cogview-3-flash',
apiKey: ''
}
}
/**
* 语言选择持久化(与 i18n/index.ts 中的初始化配合)
*/
watch(
() => locale.value,
newVal => {
localStorage.setItem('language', newVal)
}
)
/**
* API 配置持久化:任何字段变化都会更新 localStorage
*/
watch(
() => apiConfig,
newVal => {
localStorage.setItem('api_mode', newVal.mode)
localStorage.setItem('chat_baseUrl', newVal.chat.baseUrl)
localStorage.setItem('chat_model', newVal.chat.model)
localStorage.setItem('chat_apiKey', newVal.chat.apiKey)
localStorage.setItem('image_baseUrl', newVal.image.baseUrl)
localStorage.setItem('image_model', newVal.image.model)
localStorage.setItem('image_apiKey', newVal.image.apiKey)
},
{ deep: true }
)
/**
* 设置弹窗开关(由顶部导航触发)
*/
const showSettings = ref(false)
/**
* VueFlow 实例能力集合:节点/边增删改与视图控制
*/
const {
addNodes,
addEdges,
setNodes,
setEdges,
nodes: flowNodes,
edges: flowEdges,
updateNode,
fitView,
onNodeDragStart,
onNodeDragStop
} = useVueFlow()
/**
* 全局输入与对话状态
*/
const ideaInput = ref('')
const isLoading = ref(false)
const previewImageUrl = ref<string | null>(null)
const showResetConfirm = ref(false)
const showSummaryModal = ref(false)
const isSummarizing = ref(false)
const summaryContent = ref('')
const draggingNodeId = ref<string | null>(null)
/**
* 交互:按住 Space 启用“抓手拖拽画布”
* - isSpacePressed 用于在 UI 层展示手型光标
* - panOnDrag 控制 VueFlow 的拖拽行为(按 Space 时总是允许拖拽画布)
*/
const panOnDrag = ref(true)
const isSpacePressed = ref(false)
window.addEventListener('keydown', e => {
if (e.code === 'Space' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
isSpacePressed.value = true
panOnDrag.value = true
}
})
window.addEventListener('keyup', e => {
if (e.code === 'Space') {
isSpacePressed.value = false
}
})
/**
* 用拖拽节点作为“当前激活节点”的一种来源,便于高亮路径/聚焦
*/
onNodeDragStart(e => {
draggingNodeId.value = e.node.id
})
onNodeDragStop(() => {
draggingNodeId.value = null
})
/**
* 当前激活节点 id
* 优先级:正在展开的节点 > 选中的节点 > 正在拖拽的节点
*/
const activeNodeId = computed(() => {
const expandingNode = flowNodes.value.find(n => n.data.isExpanding)
const selectedNode = flowNodes.value.find(n => n.selected)
return expandingNode?.id || selectedNode?.id || draggingNodeId.value
})
/**
* 获取某节点所有后代节点 id用于激活路径计算
*/
const getDescendantIds = (nodeId: string, ids: Set<string> = new Set()): Set<string> => {
flowEdges.value.forEach(edge => {
if (edge.source === nodeId) {
ids.add(edge.target)
getDescendantIds(edge.target, ids)
}
})
return ids
}
/**
* 当前激活路径(节点集合 + 边集合)
* - 向上:从激活节点回溯到根
* - 向下:包含激活节点的所有后代
* 用于:
* - 节点高亮/弱化
* - 边高亮/动画
*/
const activePath = computed(() => {
const nodeIds = new Set<string>()
const edgeIds = new Set<string>()
if (!activeNodeId.value) return { nodeIds, edgeIds }
const targetId = activeNodeId.value
nodeIds.add(targetId)
let currentId = targetId
while (currentId) {
const edge = flowEdges.value.find(e => e.target === currentId)
if (edge) {
edgeIds.add(edge.id)
nodeIds.add(edge.source)
currentId = edge.source
} else {
break
}
}
const descendantIds = getDescendantIds(targetId)
descendantIds.forEach(id => nodeIds.add(id))
flowEdges.value.forEach(edge => {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
edgeIds.add(edge.id)
}
})
return { nodeIds, edgeIds }
})
const config = reactive({
edgeColor: '#fed7aa',
edgeStyle: 'smoothstep',
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true
})
const lastAppliedStatus = ref('')
/**
* 根据激活路径与配置,动态更新边的样式(高亮/透明度/动画)
* - 通过 lastAppliedStatus 避免无效重复 setEdges
*/
watch(
[() => activeNodeId.value, () => config.edgeColor, () => flowEdges.value.length, () => flowNodes.value.some(n => n.data.isExpanding)],
([, newColor, , anyExpanding]) => {
const { edgeIds } = activePath.value
const edgeIdsStr = Array.from(edgeIds).sort().join(',')
const currentStatus = `${edgeIdsStr}-${newColor}-${anyExpanding}`
if (lastAppliedStatus.value === currentStatus) return
lastAppliedStatus.value = currentStatus
setEdges(
flowEdges.value.map(edge => {
const isHighlighted = edgeIds.has(edge.id)
const isExpanding = !!flowNodes.value.find(n => n.id === edge.source)?.data.isExpanding
return {
...edge,
animated: isHighlighted || isExpanding,
style: {
...edge.style,
stroke: isHighlighted ? newColor : `${newColor}33`,
strokeWidth: isHighlighted ? 3 : 2,
transition: 'all 0.3s ease'
}
}
})
)
},
{ immediate: true }
)
/**
* 将网络/接口异常转换为用户可读的错误文案
*/
const getErrorMessage = (error: any) => {
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return t('common.error.cors')
}
if (error.status === 429) return t('common.error.rateLimit')
if (error.status === 400) return t('common.error.badRequest')
if (error.status >= 500) return t('common.error.serverError')
return error.message || t('common.error.unknown')
}
/**
* 视图:将根节点居中显示
*/
const centerRoot = () => {
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (rootNode) {
fitView({ nodes: [rootNode.id], padding: 2, duration: 800 })
}
}
/**
* 布局:从根节点开始按“横向树形”重新排布节点位置
* - 主要用于整理节点过多导致的视觉拥挤
*/
const resetLayout = () => {
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
const visited = new Set<string>()
const layoutNode = (nodeId: string, x: number, y: number) => {
if (visited.has(nodeId)) return
visited.add(nodeId)
const node = flowNodes.value.find(n => n.id === nodeId)
if (node) {
node.position = { x, y }
const childEdges = flowEdges.value.filter(e => e.source === nodeId)
childEdges.forEach((edge, index) => {
const offsetX = 450
const totalHeight = (childEdges.length - 1) * 280
const startY = y - totalHeight / 2
const offsetY = index * 280
layoutNode(edge.target, x + offsetX, startY + offsetY)
})
}
}
layoutNode(rootNode.id, 50, 300)
setTimeout(() => {
fitView({ padding: 0.2, duration: 800 })
}, 100)
}
/**
* 导出:将当前树形结构导出为 Markdown
* - 以 root 为标题
* - 子节点按缩进列表输出
* - deepDive 生成的详细内容以引用块输出
*/
const exportMarkdown = () => {
if (flowNodes.value.length === 0) return
const rootNode = flowNodes.value.find(n => n.data.type === 'root')
if (!rootNode) return
let markdown = `# ${rootNode.data.label}\n\n`
const buildMarkdown = (parentId: string, level: number) => {
const children = flowEdges.value
.filter(e => e.source === parentId)
.map(e => flowNodes.value.find(n => n.id === e.target))
.filter(n => n !== undefined)
children.forEach(child => {
const indent = ' '.repeat(level - 1)
markdown += `${indent}- ${child!.data.label}\n`
if (child!.data.detailedContent) {
const detailIndent = ' '.repeat(level)
markdown += `${detailIndent}> ${child!.data.detailedContent.replace(/\n/g, `\n${detailIndent}> `)}\n`
}
buildMarkdown(child!.id, level + 1)
})
}
buildMarkdown(rootNode.id, 1)
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = `thinkflow-${rootNode.data.label}-${Date.now()}.md`
link.href = url
link.click()
URL.revokeObjectURL(url)
}
/**
* 总结:基于当前所有节点信息生成一段总结文本
* - 结果展示在 SummaryModal
*/
const generateSummary = async () => {
if (flowNodes.value.length === 0) return
showSummaryModal.value = true
isSummarizing.value = true
summaryContent.value = ''
const nodesInfo = flowNodes.value.map(n => ({
label: n.data.label,
description: n.data.description,
type: n.data.type
}))
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [
{
role: 'user',
content: t('prompts.summaryPrompt', {
nodes: JSON.stringify(nodesInfo, null, 2)
})
}
]
})
})
if (!response.ok) throw new Error('Summary request failed')
const data = await response.json()
summaryContent.value = data.choices[0].message.content
} catch (error) {
console.error('Summary Generation Error:', error)
summaryContent.value = t('common.error.unknown')
} finally {
isSummarizing.value = false
}
}
/**
* 图片:为指定节点生成配图
* - 节点会进入 isImageLoading 状态
* - 成功后写入 imageUrl用于节点卡片与预览弹窗展示
*/
const generateNodeImage = async (nodeId: string, prompt: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node || node.data.isImageLoading) return
updateNode(nodeId, ((n: any) => ({ ...n, selected: true, zIndex: 1000 })) as any)
updateNode(nodeId, { data: { ...node.data, isImageLoading: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.image : apiConfig.image
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
prompt: t('prompts.image', { prompt })
})
})
if (!response.ok) {
const error: any = new Error('Image request failed')
error.status = response.status
throw error
}
const data = await response.json()
const imageUrl = data.data[0].url
updateNode(nodeId, { data: { ...node.data, imageUrl, isImageLoading: false, error: null } })
} catch (error: any) {
console.error('Image Generation Error:', error)
updateNode(nodeId, { data: { ...node.data, isImageLoading: false, error: getErrorMessage(error) } })
}
}
/**
* 深挖:针对某个节点生成更详细的解释/拓展内容
* - 若已有 detailedContent 且未展开,则直接展开(避免重复请求)
*/
const deepDive = async (nodeId: string, topic: string) => {
const node = flowNodes.value.find(n => n.id === nodeId)
if (!node) return
updateNode(nodeId, ((n: any) => ({ ...n, selected: true, zIndex: 1000 })) as any)
if (node.data.detailedContent && !node.data.isDetailExpanded) {
updateNode(nodeId, { data: { ...node.data, isDetailExpanded: true } })
return
}
if (node.data.isDeepDiving) return
updateNode(nodeId, { data: { ...node.data, isDeepDiving: true, isDetailExpanded: true, error: null } })
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [{ role: 'user', content: t('prompts.deepDivePrompt', { topic }) }]
})
})
if (!response.ok) {
const error: any = new Error('Deep dive request failed')
error.status = response.status
throw error
}
const data = await response.json()
const content = data.choices[0].message.content
updateNode(nodeId, { data: { ...node.data, detailedContent: content, isDeepDiving: false, error: null } })
} catch (error: any) {
console.error('Deep Dive Error:', error)
updateNode(nodeId, { data: { ...node.data, isDeepDiving: false, error: getErrorMessage(error) } })
}
}
/**
* 生成从根到指定节点的“上下文路径”文本,用于二次扩展时给模型更明确的上下文
*/
const findPathToNode = (nodeId: string): string[] => {
const path: string[] = []
let currentId = nodeId
while (currentId) {
const node = flowNodes.value.find(n => n.id === currentId)
if (node) {
path.unshift(`${node.data.label} (${node.data.description})`)
const edge = flowEdges.value.find(e => e.target === currentId)
currentId = edge ? edge.source : ''
} else {
break
}
}
return path
}
/**
* 将模型返回的子节点数组写入画布,并连边到 parentId
*/
const processSubNodes = (subNodes: any[], parentId: string, baseX: number, baseY: number) => {
subNodes.forEach((item: any, index: number) => {
const childId = `node-${Date.now()}-${index}`
const offsetX = 450
const offsetY = (index - (subNodes.length - 1) / 2) * 280
addNodes({
id: childId,
type: 'window',
position: { x: baseX + offsetX, y: baseY + offsetY },
data: {
label: item.text,
description: item.description,
type: 'child',
followUp: '',
isExpanding: false,
isImageLoading: false,
isTitleExpanded: false,
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
addEdges({
id: `e-${parentId}-${childId}`,
source: parentId,
target: childId,
animated: true,
style: { stroke: config.edgeColor, strokeWidth: 2 },
markerEnd: MarkerType.ArrowClosed
})
})
}
/**
* 生成/扩展节点
* - 无 parentNode创建 root 节点并生成第一层子节点
* - 有 parentNode基于选中节点生成下一层子节点支持 followUp 作为追加需求)
*
* 接口约定:
* - chat completion 返回 message.content 为 JSON 字符串
* - response_format: { type: 'json_object' } 用于提高 JSON 输出稳定性
*/
const expandIdea = async (param?: any, customInput?: string) => {
const parentNode = param && param.id ? param : undefined
const text = customInput || (parentNode ? parentNode.data.label : ideaInput.value)
if (!text || (parentNode ? parentNode.data.isExpanding : isLoading.value)) return
let currentParentId = parentNode?.id
if (!parentNode) {
isLoading.value = true
setNodes([])
setEdges([])
const rootId = 'root-' + Date.now()
currentParentId = rootId
addNodes({
id: rootId,
type: 'window',
position: { x: 50, y: 300 },
data: {
label: text,
description: t('node.coreIdea'),
type: 'root',
isExpanding: true,
isTitleExpanded: false,
followUp: '',
error: null
},
sourcePosition: Position.Right,
targetPosition: Position.Left
})
ideaInput.value = ''
} else {
const node = flowNodes.value.find(n => n.id === parentNode.id)
if (node) {
updateNode(parentNode.id, {
data: {
...node.data,
isExpanding: true,
isDetailExpanded: false,
error: null
}
})
}
}
const systemPrompt = t('prompts.system')
let userMessage = ''
if (parentNode) {
const path = findPathToNode(parentNode.id)
userMessage = `[${t('prompts.contextPath')}]: ${path.join(' -> ')}\n[${t('prompts.selectedNode')}]: ${parentNode.data.label}\n[${t('prompts.newRequirement')}]: ${customInput || t('prompts.continue')}`
} else {
userMessage = `${t('prompts.coreIdeaPrefix')}: ${text}`
}
const useConfig = apiConfig.mode === 'default' ? DEFAULT_CONFIG.chat : apiConfig.chat
const finalApiKey = apiConfig.mode === 'default' ? useConfig.apiKey || API_KEY : useConfig.apiKey
try {
const response = await fetch(useConfig.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${finalApiKey}`
},
body: JSON.stringify({
model: useConfig.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' },
temperature: 0.8
})
})
if (!response.ok) {
const error: any = new Error('AI request failed')
error.status = response.status
throw error
}
const data = await response.json()
const result = JSON.parse(data.choices[0].message.content)
const parentNodeObj = flowNodes.value.find(n => n.id === currentParentId)
const startX = parentNodeObj ? parentNodeObj.position.x : 50
const startY = parentNodeObj ? parentNodeObj.position.y : 300
processSubNodes(result.nodes, currentParentId, startX, startY)
if (!parentNode) {
setTimeout(() => {
const childEdges = flowEdges.value.filter(e => e.source === currentParentId)
const childIds = childEdges.map(e => e.target)
const nodesToFit = [currentParentId, ...childIds.slice(0, 3)]
fitView({
nodes: nodesToFit,
padding: 0.25,
duration: 1000
})
}, 100)
}
} catch (error: any) {
console.error('Expansion Error:', error)
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
updateNode(currentParentId, { data: { ...node.data, error: getErrorMessage(error) } })
}
} finally {
const node = flowNodes.value.find(n => n.id === currentParentId)
if (node) {
node.data.isExpanding = false
}
isLoading.value = false
}
}
/**
* 立即清空当前画布与输入,并关闭确认弹窗
*/
const executeReset = () => {
ideaInput.value = ''
setNodes([])
setEdges([])
showResetConfirm.value = false
}
/**
* 新会话入口
* - 若当前已有节点:弹出二次确认
* - 若为空:直接清空(等价 executeReset
*/
const startNewSession = () => {
if (flowNodes.value.length > 0) {
showResetConfirm.value = true
return
}
executeReset()
}
return {
apiConfig,
DEFAULT_CONFIG,
showSettings,
ideaInput,
isLoading,
previewImageUrl,
showResetConfirm,
showSummaryModal,
isSummarizing,
summaryContent,
panOnDrag,
isSpacePressed,
config,
flowNodes,
flowEdges,
activeNodeId,
activePath,
updateNode,
fitView,
resetLayout,
centerRoot,
startNewSession,
executeReset,
generateSummary,
exportMarkdown,
generateNodeImage,
deepDive,
expandIdea
}
}

View File

@@ -31,7 +31,7 @@
"title": "ThinkFlow AI", "title": "ThinkFlow AI",
"subtitle": "思维导图 & AI 助手", "subtitle": "思维导图 & AI 助手",
"placeholder": "在这里输入您的想法...", "placeholder": "在这里输入您的想法...",
"execute": "发散思维", "execute": "提交问题",
"reset": "重置", "reset": "重置",
"fit": "适配", "fit": "适配",
"layout": "布局", "layout": "布局",
@@ -46,7 +46,7 @@
"settings": { "settings": {
"title": "API 设置", "title": "API 设置",
"subtitle": "配置您的 AI 接口地址", "subtitle": "配置您的 AI 接口地址",
"textGen": "文本生成 (Chat)", "textGen": "文本生成",
"imageGen": "图片生成", "imageGen": "图片生成",
"baseUrl": "接口地址 (Base URL)", "baseUrl": "接口地址 (Base URL)",
"modelName": "模型名称", "modelName": "模型名称",
@@ -55,7 +55,7 @@
"placeholderModel": "模型名称", "placeholderModel": "模型名称",
"placeholderKey": "sk-...", "placeholderKey": "sk-...",
"defaultModeTitle": "使用默认接口", "defaultModeTitle": "使用默认接口",
"defaultModeDesc": "请求目前正通过系统优化的默认 API 服务进行路由。" "defaultModeDesc": "目前使用Bigmodel(智谱)提供的免费文本生成接口glm-4-flash和图片生成接口cogview-3-flash为了更好的体验请自定义接口。"
}, },
"node": { "node": {
"root": "根节点", "root": "根节点",

8
src/vite-env.d.ts vendored
View File

@@ -1,9 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ZHIPU_AI_API_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}