优化代码,增加注释
This commit is contained in:
166
README.md
166
README.md
@@ -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 node’s 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
168
README.zh-CN.md
Normal 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 插件**:
|
||||||
|
- Background(Dots/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`:本地预览构建产物
|
||||||
1429
src/App.vue
1429
src/App.vue
File diff suppressed because it is too large
Load Diff
58
src/components/BottomBar.vue
Normal file
58
src/components/BottomBar.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 底部输入条
|
||||||
|
* - 接收 v-model(modelValue)作为输入内容
|
||||||
|
* - 触发 expand 事件,由 App/useThinkFlow 执行“生成/扩展”
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 图标:输入提示与执行态
|
||||||
|
import { RefreshCw, Terminal, Zap } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props:
|
||||||
|
* - t:i18n 翻译函数
|
||||||
|
* - 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>
|
||||||
38
src/components/ImagePreviewModal.vue
Normal file
38
src/components/ImagePreviewModal.vue
Normal 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>
|
||||||
68
src/components/ResetConfirmModal.vue
Normal file
68
src/components/ResetConfirmModal.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 重置确认弹窗
|
||||||
|
* - 用于“新会话/清空画布”前的二次确认
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 图标:旋转提示
|
||||||
|
import { RefreshCw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props:
|
||||||
|
* - show:弹窗显示开关
|
||||||
|
* - t:i18n 翻译函数
|
||||||
|
*/
|
||||||
|
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>
|
||||||
177
src/components/SettingsModal.vue
Normal file
177
src/components/SettingsModal.vue
Normal 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:弹窗显示开关
|
||||||
|
* - t:i18n 翻译函数
|
||||||
|
* - 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>
|
||||||
82
src/components/SummaryModal.vue
Normal file
82
src/components/SummaryModal.vue
Normal 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:弹窗显示开关
|
||||||
|
* - t:i18n 翻译函数
|
||||||
|
* - 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
238
src/components/TopNav.vue
Normal 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:
|
||||||
|
* - t:i18n 翻译函数
|
||||||
|
* - 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>
|
||||||
239
src/components/WindowNode.vue
Normal file
239
src/components/WindowNode.vue
Normal 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/selected:VueFlow 提供的节点数据
|
||||||
|
* - 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>
|
||||||
755
src/composables/useThinkFlow.ts
Normal file
755
src/composables/useThinkFlow.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
8
src/vite-env.d.ts
vendored
@@ -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
18
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user