From 3bf3919f603ae3c576584a513ee0c6cd6cade4a5 Mon Sep 17 00:00:00 2001 From: liuziting <57311725+liu-ziting@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:57:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 166 ++- README.zh-CN.md | 168 +++ src/App.vue | 1439 ++------------------------ src/components/BottomBar.vue | 58 ++ src/components/ImagePreviewModal.vue | 38 + src/components/ResetConfirmModal.vue | 68 ++ src/components/SettingsModal.vue | 177 ++++ src/components/SummaryModal.vue | 82 ++ src/components/TopNav.vue | 238 +++++ src/components/WindowNode.vue | 239 +++++ src/composables/useThinkFlow.ts | 755 ++++++++++++++ src/i18n/locales/zh.json | 6 +- src/vite-env.d.ts | 8 - tsconfig.json | 18 + 14 files changed, 2107 insertions(+), 1353 deletions(-) create mode 100644 README.zh-CN.md create mode 100644 src/components/BottomBar.vue create mode 100644 src/components/ImagePreviewModal.vue create mode 100644 src/components/ResetConfirmModal.vue create mode 100644 src/components/SettingsModal.vue create mode 100644 src/components/SummaryModal.vue create mode 100644 src/components/TopNav.vue create mode 100644 src/components/WindowNode.vue create mode 100644 src/composables/useThinkFlow.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index d602f52..e846a6f 100644 --- a/README.md +++ b/README.md @@ -1 +1,165 @@ -# HouseGPT \ No newline at end of file +# 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 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..182f649 --- /dev/null +++ b/README.zh-CN.md @@ -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`:本地预览构建产物 diff --git a/src/App.vue b/src/App.vue index 7dde63b..e108fb8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,908 +1,105 @@ diff --git a/src/components/BottomBar.vue b/src/components/BottomBar.vue new file mode 100644 index 0000000..f541b71 --- /dev/null +++ b/src/components/BottomBar.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/ImagePreviewModal.vue b/src/components/ImagePreviewModal.vue new file mode 100644 index 0000000..d28a396 --- /dev/null +++ b/src/components/ImagePreviewModal.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/ResetConfirmModal.vue b/src/components/ResetConfirmModal.vue new file mode 100644 index 0000000..71c4295 --- /dev/null +++ b/src/components/ResetConfirmModal.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue new file mode 100644 index 0000000..d4147c8 --- /dev/null +++ b/src/components/SettingsModal.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/components/SummaryModal.vue b/src/components/SummaryModal.vue new file mode 100644 index 0000000..6fe3d74 --- /dev/null +++ b/src/components/SummaryModal.vue @@ -0,0 +1,82 @@ + + + + diff --git a/src/components/TopNav.vue b/src/components/TopNav.vue new file mode 100644 index 0000000..d321bbe --- /dev/null +++ b/src/components/TopNav.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/components/WindowNode.vue b/src/components/WindowNode.vue new file mode 100644 index 0000000..373f0d4 --- /dev/null +++ b/src/components/WindowNode.vue @@ -0,0 +1,239 @@ + + + diff --git a/src/composables/useThinkFlow.ts b/src/composables/useThinkFlow.ts new file mode 100644 index 0000000..57ec998 --- /dev/null +++ b/src/composables/useThinkFlow.ts @@ -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 }) { + /** + * 默认模式下的 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(null) + const showResetConfirm = ref(false) + const showSummaryModal = ref(false) + const isSummarizing = ref(false) + const summaryContent = ref('') + + const draggingNodeId = ref(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 = new Set()): Set => { + 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() + const edgeIds = new Set() + + 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() + 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 + } +} + diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 4497195..5d55968 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -31,7 +31,7 @@ "title": "ThinkFlow AI", "subtitle": "思维导图 & AI 助手", "placeholder": "在这里输入您的想法...", - "execute": "发散思维", + "execute": "提交问题", "reset": "重置", "fit": "适配", "layout": "布局", @@ -46,7 +46,7 @@ "settings": { "title": "API 设置", "subtitle": "配置您的 AI 接口地址", - "textGen": "文本生成 (Chat)", + "textGen": "文本生成", "imageGen": "图片生成", "baseUrl": "接口地址 (Base URL)", "modelName": "模型名称", @@ -55,7 +55,7 @@ "placeholderModel": "模型名称", "placeholderKey": "sk-...", "defaultModeTitle": "使用默认接口", - "defaultModeDesc": "请求目前正通过系统优化的默认 API 服务进行路由。" + "defaultModeDesc": "目前使用Bigmodel(智谱)提供的免费文本生成接口glm-4-flash和图片生成接口cogview-3-flash,为了更好的体验请自定义接口。" }, "node": { "root": "根节点", diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8b70eb2..11f02fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,9 +1 @@ /// - -interface ImportMetaEnv { - readonly VITE_ZHIPU_AI_API_KEY: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c93fd35 --- /dev/null +++ b/tsconfig.json @@ -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"] +}