feat: 工作台外壳重构 + /home 改为智能助手 + 产品更名
前端 - MainLayout: 移除顶部标题栏,Workspace Tabs 提至最顶层,通知铃随行 - 账号入口由右上角移至左下角侧边栏底部(下拉:我的资料 / 退出登录), 折叠态仅保留头像 - /home 现渲染智能助手并作为其规范 URL;/tools/smart-assistant 保留为重定向别名 - 工作台总览移至 /overview,侧边栏入口同步调整 - workbench store: 新增 tab 归一化,迁移已持久化的旧标签与旧路由 - 智能助手欢迎语改为「我 Bosheng 数字员工,我帮你」 - 最佳实践卡片: 4 张减为 3 张,图片块高度减半(aspect-ratio 4/3 → 8/3) 产品更名 - 后端 system prompt、CLAUDE.md、PROJECT_STATE.md 及设计文档: 博昇内部培训平台 → EAI 数字员工平台 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
# DR04 — 全局 Chat-First(WorkBuddy 风格)改造方案
|
||||||
|
|
||||||
|
> **版本:V1.1**
|
||||||
|
> **日期:2026-09-14**
|
||||||
|
> **状态:实施中——路由接入 + 外壳重写已完成,AI 工具逐页迁移进行中**
|
||||||
|
> **目标对齐:** `eai_agentplatform/frontend/src/views/tools/CHAT_UI_PREVIEW.html`
|
||||||
|
> **参考:** `CHAT_FIRST_REDESIGN_PLAN.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 命名统一(改造前置要求)
|
||||||
|
|
||||||
|
> 本项目为 **数字员工平台 / AI Agent 平台(eai_agentplatform)**,不再是旧培训平台。
|
||||||
|
> 改造过程中,**必须移除 / 替换**所有旧培训平台命名与标题:
|
||||||
|
|
||||||
|
| 旧命名(需移除/替换) | 统一替换为 |
|
||||||
|
|---|---|
|
||||||
|
| `eaisalestrain_app` / `eai_ap_app` / `pj0231` | `eai_agentplatform` / `数字员工平台` |
|
||||||
|
| 博昇内部培训平台 | 数字员工平台 / EAI Agent 平台 |
|
||||||
|
| 培训平台定位(知识库+考试+素材审批) | AI 数字员工平台定位 |
|
||||||
|
|
||||||
|
- 涉及范围:`CLAUDE.md`、`CODING_RULES.md`、全部 `views/**` 与共享组件中的标题/文案、`docs` 相关文档。
|
||||||
|
- `CHAT_UI_PREVIEW.html`、`CHAT_FIRST_REDESIGN_PLAN.md` 中的旧标题一并替换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 改造目标(对齐 CHAT_UI_PREVIEW.html)
|
||||||
|
|
||||||
|
| 视觉元素 | 定义 |
|
||||||
|
|---|---|
|
||||||
|
| 主动能区背景 | `#f0f2f5` 浅灰 |
|
||||||
|
| 侧边栏 | 窄 `220px`,可折叠至 `60px`;背景 `#f7f8fa`;含「新建任务」+ 主导航 + 任务列表 + 用户信息 |
|
||||||
|
| 顶栏 | 极简 `48px`:`选择工作空间 ▾` / `默认权限 ▾` + `🔔` |
|
||||||
|
| 主色 | 按钮渐变 `#409eff→#2b63d9`;激活高亮 `#e1effe` |
|
||||||
|
| 欢迎态 | 大标题 + 分类胶囊 + 模式胶囊 + 大输入框(文件/语音/引用)+ 圆形发送钮 + 最佳实践卡片 |
|
||||||
|
| 聊天态 | 用户右对齐深蓝 `#2b63d9`,AI 左对齐白底卡片,消息操作钮(复制/下载/追问) |
|
||||||
|
| 圆角 / 描边 / 阴影 | `10-16px`、`#e8ecf1`、柔和阴影 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 改造范围与策略
|
||||||
|
|
||||||
|
### 2.1 全局外壳(MainLayout.vue → WorkBuddy Shell)
|
||||||
|
- 移除:深蓝渐变顶栏(72px)、应用卡片左栏、底部运行面板。
|
||||||
|
- 新增:极简顶栏 + 窄侧边栏(新建任务 / 助理 / 项目 / 专家·技能·连接器 / 定时任务 / 资料库 / 更多 / 任务列表 / 用户信息)。
|
||||||
|
- 内容区承载 Welcome 态 / Chat 态两种容器。
|
||||||
|
- **保留全部路由与业务跳转,仅改外观与信息架构。**
|
||||||
|
|
||||||
|
### 2.2 AI 工具页(完整 Chat-First)
|
||||||
|
| 页面 | 落地 |
|
||||||
|
|---|---|
|
||||||
|
| 智能助手 `SmartAssistantPage.vue` | 完整 Chat 优先 |
|
||||||
|
| 文档翻译 / 文案校对 / 语音转写 / 批量提取 | 表单 → Chat + 文件上传 |
|
||||||
|
| 合同审查 | Chat + 可展开配置面板 |
|
||||||
|
| 报告生成 | 保留复合表单,统一视觉 |
|
||||||
|
|
||||||
|
复用已有组件并统一到预览标准:`ChatLayout.vue` / `ChatInputBar.vue` / `ChatMessage.vue` / `HistorySidebar.vue`。
|
||||||
|
新增共享组件:全局顶栏、Welcome 欢迎态、分类/模式胶囊、最佳实践卡片。
|
||||||
|
|
||||||
|
### 2.3 业务页面(保留功能,统一视觉)
|
||||||
|
首页、工作台(专员/市场/工坊/知识库/控制台)、考试、系统管理、产品、课程等页面:
|
||||||
|
**保留现有内容与交互逻辑**,仅统一采用 WorkBuddy 视觉语言,并嵌入全局外壳容器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 分阶段实施
|
||||||
|
|
||||||
|
1. **外壳**:重写 `MainLayout.vue` 为 WorkBuddy Shell + 极简顶栏。
|
||||||
|
2. **基建**:统一 `ChatLayout` 等共享组件到预览标准。
|
||||||
|
3. **AI 工具**:逐页 Chat-First(助手→翻译→校对→转录→提取→审查)。
|
||||||
|
4. **业务页**:逐页统一视觉,接入新外壳。
|
||||||
|
5. **命名替换**:按第 0 节替换旧培训标题/文案。
|
||||||
|
6. **回归**:全部路由可达、登录/权限、交互不被破坏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 影响与风险
|
||||||
|
|
||||||
|
- 改动面大:`MainLayout.vue`、全部 `views/**`、共享组件、`global.css`。
|
||||||
|
- 业务逻辑不变,仅视觉与信息架构变化。
|
||||||
|
- 建议分批提交、可回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 实施记录
|
||||||
|
|
||||||
|
### 5.1 已拍板决策
|
||||||
|
|
||||||
|
| 议题 | 决定 |
|
||||||
|
|---|---|
|
||||||
|
| 入口模型 | **Chat-First 为主入口,`/apps/*` 业务工作台页面退役** |
|
||||||
|
| 推进顺序 | **先接路由导航,再改视觉** |
|
||||||
|
|
||||||
|
### 5.2 关键发现:Chat-First 页面此前「不可达」
|
||||||
|
|
||||||
|
阶段 1 之前,`grep -rn "tools/" src/ --include=*.vue --include=*.js | grep -v "views/tools/"` **无任何输出**——
|
||||||
|
即没有任何 `router.push` / `<router-link>` / 配置项指向 `/tools/*` 或 `/report-gen`。
|
||||||
|
|
||||||
|
commit `5c0870a` 的 Chat 界面并非损坏,而是**入口全部指向 `/apps/*`**(渲染 `BusinessAppPage.vue`,内含 0 个聊天组件)。
|
||||||
|
用户只能手动敲 URL 才能看到新界面。**这不是旧代码问题,是路由未接入。**
|
||||||
|
|
||||||
|
### 5.3 阶段完成情况
|
||||||
|
|
||||||
|
| 阶段 | 状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. 路由接入 | ✅ 完成 | 新增 `chat-tools` 面;7 个 AI 工具入口;`/` 与登录后均落到 `/tools/smart-assistant` |
|
||||||
|
| 2. 外壳重写 | ✅ 完成 | `MainLayout.vue` 重写为 WorkBuddy Shell |
|
||||||
|
| 3. AI 工具逐页 | ⏳ 进行中 | 智能助手页面结构仍与预览有差异(大输入框未居中为主视觉) |
|
||||||
|
| 4. 业务页统一视觉 | ⏳ 未开始 | |
|
||||||
|
| 5. 命名替换 | ⏳ 部分 | `CLAUDE.md` / `global.css` / `Login.vue` 已改;`views/**` 存量待清 |
|
||||||
|
| 6. 回归 | ⏳ 未开始 | |
|
||||||
|
|
||||||
|
### 5.4 外壳落地细节(阶段 2)
|
||||||
|
|
||||||
|
- 单一侧边栏 `220px`(折叠 `60px`),背景 `#f7f8fa`,含「新建任务」渐变按钮 / 分组导航 / 底部用户信息。
|
||||||
|
- 极简顶栏 `48px` 移入主列,含标题 / 搜索 / 通知 / 账号菜单。
|
||||||
|
- **已移除**:深蓝渐变顶栏、应用卡片左栏、底部运行面板(`bottomTabs`)。
|
||||||
|
- 保留工作区页签(不在移除清单内)。
|
||||||
|
- 侧边栏导航项**按 `router.resolve()` 校验可达性**后再渲染,避免配置项变成死链。
|
||||||
|
|
||||||
|
### 5.5 侧边栏去重(重要)
|
||||||
|
|
||||||
|
`ChatLayout.vue` 内部**自带一份与预览稿几乎一致的工作台侧边栏**(220px / `#f7f8fa`,含新建任务、助理、项目、专家·技能·连接器、定时任务、资料库、更多、任务列表)。
|
||||||
|
外壳重写后,AI 工具页会**叠出两条 220px 侧边栏(共 440px)**。
|
||||||
|
核查其后端支撑:
|
||||||
|
|
||||||
|
- 导航项(助理/项目/定时任务/资料库/更多)仅 `emit` 事件,**无任何路由**,纯装饰。
|
||||||
|
- 任务列表来自硬编码数组(`SmartAssistantPage.vue` 中 `taskItems = ref([...])`,`:history-count="5"` 为字面量),**系统内无会话存储**。
|
||||||
|
- 唯一真实功能是「新建任务」→ `handleNewChat` 重置消息。
|
||||||
|
|
||||||
|
**处置:** 全局外壳是唯一侧边栏。`ChatLayout` 新增 `showSidebar` 属性,**默认 `false`**;浮动折叠把手同样受控。
|
||||||
|
外壳「新建任务」通过 `window` 事件 `wb:new-task` 广播,`ChatLayout` 监听后转 `newChat` 事件,
|
||||||
|
使 6 个 AI 工具页无需各自改造即可保留重置行为。
|
||||||
|
|
||||||
|
### 5.6 与预览稿的有意偏离
|
||||||
|
|
||||||
|
预览稿顶栏含 `选择工作空间 ▾` / `默认权限 ▾`,侧边栏含「定时任务」「项目」。
|
||||||
|
本系统**无对应数据与路由**,故不照搬,改用真实可达路由填充导航——不制造无功能的装饰元素。
|
||||||
|
|
||||||
|
### 5.7 资料库降为一级入口 + 二级页签
|
||||||
|
|
||||||
|
**需求:** 资料库作为「AI 工具」下的一级菜单;其下二级菜单改为页签(不再占用侧边栏)。
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
|
||||||
|
- 侧边栏「AI 工具」组新增单一入口「资料库」,指向 `knowledgeLinks[0]`;
|
||||||
|
该入口带 `match: libraryRoutes`,在五个子路由任意一个下均保持高亮。
|
||||||
|
- 原独立「资料库」分组移除(`knowledgeLinks` 五项不再逐条占位)。
|
||||||
|
- 新增 `src/components/LibraryTabs.vue`:下划线式二级页签,位于内容区顶部、固定不随页面滚动;
|
||||||
|
可见子项 ≤1 时不渲染;`知识库管理` 沿用 `adminOnly` 过滤(非管理员看不到该页签)。
|
||||||
|
|
||||||
|
**路由不变:** 五个子路由(`/knowledge-hub`、`/knowledge-management`、`/products`、`/courses`、`/company-train`)
|
||||||
|
保持原路径,深链与站内既有跳转不受影响——本次仅调整信息架构,未改路由表。
|
||||||
|
|
||||||
|
**内容区调整:** `.wb-content` 由 `overflow:auto` 改为 flex 列 + 内层 `.wb-content-scroll` 承担滚动,
|
||||||
|
使二级页签固定。页面自身滚动行为不变。
|
||||||
|
|
||||||
|
### 5.8 待处理
|
||||||
|
|
||||||
|
- **`/apps/*` 退役**:涉及 `BusinessAppPage.vue`、`businessApps` 配置、`workbench` store 持久化的 `openTabs`(默认值仍为 `/home`)。
|
||||||
|
- **配置与路由不一致(既有缺陷)**:`businessApps` 有 10 项,路由仅定义 7 条 `/apps/*`。
|
||||||
|
`logistics-fulfillment`、`hr-email-sorter`、`resume-processor` 三项**无路由**,且旧左栏(`getBusinessAppsByTier('industry')`,5 项中 3 项无路由)本就在渲染死链。
|
||||||
|
当前已在渲染层过滤;配置层清理随 `/apps/*` 退役一并处理。
|
||||||
|
- `guards.js` 非管理员回退仍指向 `/home`(`5.3` 阶段 4 一并处理)。
|
||||||
@@ -13,3 +13,4 @@
|
|||||||
| `DR01_Theme_System.md` | 主题系统(Element Plus 变量覆盖) |
|
| `DR01_Theme_System.md` | 主题系统(Element Plus 变量覆盖) |
|
||||||
| `DR02_Navigation_Rules.md` | 导航规则(菜单/权限显示/路由守卫) |
|
| `DR02_Navigation_Rules.md` | 导航规则(菜单/权限显示/路由守卫) |
|
||||||
| `DR03_Interaction_Rules.md` | 交互规范(按钮状态/反馈/弹窗) |
|
| `DR03_Interaction_Rules.md` | 交互规范(按钮状态/反馈/弹窗) |
|
||||||
|
| `DR04_ChatFirst_Redesign.md` | 全局 Chat-First(WorkBuddy 风格)改造方案 |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# eai_agentplatform_app 博昇内部培训平台
|
# eai_agentplatform_app EAI 数字员工平台(AI Agent Platform)
|
||||||
|
|
||||||
纯内网本地化、内部专属员工培训系统。
|
纯内网本地化的企业 AI 数字员工平台。
|
||||||
用于新人入职培训、公司认知、产品知识学习、销售能力培训、统一考试验收。
|
围绕数字员工(专员)、知识库底座、自动化工坊、考试评估与系统管理组织业务能力。
|
||||||
配套全局 AI 助教 PathCoach,提供答疑、产品查询、销售情景演练。
|
配套全局 AI 助手,提供答疑、知识查询、任务拆解与业务工具。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
## 1. 项目是什么
|
## 1. 项目是什么
|
||||||
|
|
||||||
**eai_agentplatform_app** — 博昇内部培训平台。
|
**eai_agentplatform_app** — EAI 数字员工平台(AI Agent Platform)。
|
||||||
纯内网本地化、内部专属员工培训系统,用于新人入职培训、公司认知、产品知识学习、
|
纯内网本地化的企业 AI 数字员工平台,围绕数字员工(专员)、知识库底座、自动化工坊、
|
||||||
销售能力培训、统一考试验收。配套全局 AI 助教 PathCoach。
|
考试评估与系统管理组织业务能力。配套全局 AI 助手。
|
||||||
|
|
||||||
## 2. 当前状态
|
## 2. 当前状态
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func ChatMessage(c *gin.Context) {
|
|||||||
func buildSystemPrompt(ctx map[string]any, knowledge []string) string {
|
func buildSystemPrompt(ctx map[string]any, knowledge []string) string {
|
||||||
ctxJSON, _ := json.Marshal(ctx)
|
ctxJSON, _ := json.Marshal(ctx)
|
||||||
kc := strings.Join(knowledge, "\n\n")
|
kc := strings.Join(knowledge, "\n\n")
|
||||||
return fmt.Sprintf(`你是博昇内部培训平台的 AI 助教 PathCoach。
|
return fmt.Sprintf(`你是 EAI 数字员工平台的 AI 助手。
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题
|
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
systemPrompt := "你是培训平台阅卷老师。请严格依据「评分标准」对学员的简答题答案打分。" +
|
systemPrompt := "你是数字员工平台的阅卷助手。请严格依据「评分标准」对学员的简答题答案打分。" +
|
||||||
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
|
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
|
||||||
"不要输出任何其他内容。"
|
"不要输出任何其他内容。"
|
||||||
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
|
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func retrieveReportKnowledge(topic string, spaceKey string) string {
|
|||||||
// promptReportWithSections 使用用户指定章节生成报告
|
// promptReportWithSections 使用用户指定章节生成报告
|
||||||
func promptReportWithSections(req ReportGenRequest, knowledge string) []reportChapter {
|
func promptReportWithSections(req ReportGenRequest, knowledge string) []reportChapter {
|
||||||
sectionsStr := strings.Join(req.Sections, "、")
|
sectionsStr := strings.Join(req.Sections, "、")
|
||||||
systemPrompt := `你是博昇内部培训平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。`
|
systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。`
|
||||||
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
||||||
|
|
||||||
主题:%s
|
主题:%s
|
||||||
@@ -154,7 +154,7 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh
|
|||||||
|
|
||||||
// promptReportAutoChapters 让 LLM 自动规划报告章节
|
// promptReportAutoChapters 让 LLM 自动规划报告章节
|
||||||
func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportChapter {
|
func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportChapter {
|
||||||
systemPrompt := `你是博昇内部培训平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。`
|
systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。`
|
||||||
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
||||||
|
|
||||||
主题:%s
|
主题:%s
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>博昇内部培训平台</title>
|
<title>数字员工平台 · EAI Agent Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Second-level navigation for 资料库. The sidebar carries a single 资料库
|
||||||
|
entry; the sections below it switch here instead of in the sidebar. -->
|
||||||
|
<nav v-if="visibleSections.length > 1" class="library-tabs">
|
||||||
|
<button
|
||||||
|
v-for="section in visibleSections"
|
||||||
|
:key="section.route"
|
||||||
|
class="library-tab"
|
||||||
|
:class="{ active: isCurrent(section.route) }"
|
||||||
|
:title="section.caption"
|
||||||
|
@click="router.push(section.route)"
|
||||||
|
>
|
||||||
|
{{ section.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { knowledgeLinks } from '@/config/workbench'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const visibleSections = computed(() =>
|
||||||
|
knowledgeLinks.filter((section) => !section.adminOnly || auth.isAdmin)
|
||||||
|
)
|
||||||
|
|
||||||
|
function isCurrent(path) {
|
||||||
|
return route.path === path || route.path.startsWith(`${path}/`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.library-tabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8ecf1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab.active {
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #409eff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-layout" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
<div class="chat-layout" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||||
<!-- Left sidebar: conversation history -->
|
<!-- Left sidebar: conversation history.
|
||||||
<aside class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
Off by default: the global shell (MainLayout) owns the WorkBuddy sidebar,
|
||||||
|
so rendering this one too would stack two 220px sidebars. -->
|
||||||
|
<aside v-if="showSidebar" class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button class="new-chat-btn" @click="$emit('newChat')">
|
<button class="new-chat-btn" @click="$emit('newChat')">
|
||||||
<span class="btn-icon">+</span>
|
<span class="btn-icon">+</span>
|
||||||
@@ -67,8 +69,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar toggle handle -->
|
<!-- Sidebar toggle handle (only meaningful when the sidebar is shown) -->
|
||||||
<div class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
<div v-if="showSidebar" class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||||
{{ sidebarCollapsed ? '▷' : '◁' }}
|
{{ sidebarCollapsed ? '▷' : '◁' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
<!-- Welcome state -->
|
<!-- Welcome state -->
|
||||||
<div v-if="!hasMessages" class="welcome-state">
|
<div v-if="!hasMessages" class="welcome-state">
|
||||||
<slot name="welcome">
|
<slot name="welcome">
|
||||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +124,12 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
// The global shell provides navigation and the 新建任务 action, so the
|
||||||
|
// page-level sidebar stays hidden unless a caller explicitly opts in.
|
||||||
|
showSidebar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -141,11 +149,19 @@ function toggleHistory() {
|
|||||||
import { onMounted, onBeforeUnmount } from 'vue'
|
import { onMounted, onBeforeUnmount } from 'vue'
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
|
// The shell's 新建任务 button lives outside this component now, so it
|
||||||
|
// asks for a reset through a window event instead of a direct call.
|
||||||
|
window.addEventListener('wb:new-task', handleShellNewTask)
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('wb:new-task', handleShellNewTask)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleShellNewTask() {
|
||||||
|
emit('newChat')
|
||||||
|
}
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
sidebarCollapsed.value = true
|
sidebarCollapsed.value = true
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
|
// AI 工具(Chat-First 主入口)—— 全部走 ChatLayout 聊天优先界面
|
||||||
|
export const chatTools = [
|
||||||
|
{ key: 'smart-assistant', label: '智能助手', caption: '对话 / 任务拆解 / 文案生成', route: '/home' },
|
||||||
|
{ key: 'document-translate', label: '文档翻译', caption: '多语言翻译与导出', route: '/tools/document-translate' },
|
||||||
|
{ key: 'copy-proofreading', label: '文案校对', caption: '错别字与语病检查', route: '/tools/copy-proofreading' },
|
||||||
|
{ key: 'audio-transcribe', label: '语音转写', caption: '音频转文字', route: '/tools/audio-transcribe' },
|
||||||
|
{ key: 'batch-extract', label: '批量提取', caption: '字段批量抽取', route: '/tools/batch-extract' },
|
||||||
|
{ key: 'contract-review', label: '合同审查', caption: '条款完整性与风险评分', route: '/tools/contract-review' },
|
||||||
|
{ key: 'report-generation', label: '报告生成', caption: '日报 / 周报 / 复盘', route: '/report-gen' },
|
||||||
|
]
|
||||||
|
|
||||||
export const topNavItems = [
|
export const topNavItems = [
|
||||||
{ key: 'apps', label: '01 专员工作区', title: '专员工作区', route: '/home' },
|
{ key: 'chat-tools', label: 'AI 工具', title: 'AI 工具', route: '/home' },
|
||||||
{ key: 'market', label: '02 专员市场', title: '专员市场', route: '/market', adminOnly: true },
|
{ key: 'ziliaoku', label: '字料库', title: '字料库', route: '/knowledge-hub' },
|
||||||
{ key: 'studio', label: '03 工坊', title: '工坊', route: '/studio', adminOnly: true },
|
{ key: 'apps', label: '专员工作区', title: '专员工作区', route: '/home' },
|
||||||
{ key: 'knowledge-app', label: '04 知识库应用', title: '知识库应用', route: '/knowledge-hub' },
|
{ key: 'market', label: '专员市场', title: '专员市场', route: '/market', adminOnly: true },
|
||||||
{ key: 'knowledge-admin', label: '05 知识库管理', title: '知识库管理', route: '/knowledge-management', adminOnly: true },
|
{ key: 'studio', label: '工坊', title: '工坊', route: '/studio', adminOnly: true },
|
||||||
{ key: 'console', label: '06 控制台', title: '控制台', route: '/console', adminOnly: true },
|
{ key: 'console', label: '控制台', title: '控制台', route: '/console', adminOnly: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 字料库子级 Tab 导航(用于页面内 tab 切换)
|
||||||
|
export const ziliaokuTabs = [
|
||||||
|
{ key: 'chat-search', label: '知识问答', route: '/knowledge-hub' },
|
||||||
|
{ key: 'knowledge-admin', label: '知识管理', route: '/knowledge-management' },
|
||||||
|
{ key: 'product', label: '产品知识', route: '/products' },
|
||||||
|
{ key: 'sales', label: '销售培训', route: '/courses' },
|
||||||
|
{ key: 'company', label: '公司介绍', route: '/company-train' },
|
||||||
|
{ key: 'materials', label: '素材审批', route: '/knowledge/material-approve' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const businessApps = [
|
export const businessApps = [
|
||||||
@@ -697,6 +718,7 @@ const genericSurfaceContext = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkbenchSurface(path) {
|
export function getWorkbenchSurface(path) {
|
||||||
|
if (path.startsWith('/tools/') || path === '/report-gen') return 'chat-tools'
|
||||||
if (path === '/knowledge-hub' || path.startsWith('/products') || path.startsWith('/courses') || path.startsWith('/company-train')) {
|
if (path === '/knowledge-hub' || path.startsWith('/products') || path.startsWith('/courses') || path.startsWith('/company-train')) {
|
||||||
return 'knowledge-app'
|
return 'knowledge-app'
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,9 @@
|
|||||||
<div class="brand-content">
|
<div class="brand-content">
|
||||||
<el-icon class="brand-logo" :size="24"><School /></el-icon>
|
<el-icon class="brand-logo" :size="24"><School /></el-icon>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="brand-name">博昇AI研究院</div>
|
<div class="brand-name">EAI 数字员工平台</div>
|
||||||
<div class="brand-sub">内部培训平台</div>
|
<div class="brand-sub">AI Agent Platform</div>
|
||||||
<div class="brand-en">EAI-Training</div>
|
<div class="brand-en">eai_agentplatform</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const routes = [
|
|||||||
component: () => import('@/layout/MainLayout.vue'),
|
component: () => import('@/layout/MainLayout.vue'),
|
||||||
redirect: '/home',
|
redirect: '/home',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'home', name: 'Home', component: () => import('@/views/home/HomePage.vue') },
|
// 智能助手 is the front door; /home is its canonical URL.
|
||||||
|
{ path: 'home', name: 'Home', component: () => import('@/views/tools/SmartAssistantPage.vue') },
|
||||||
|
{ path: 'overview', name: 'Overview', component: () => import('@/views/home/HomePage.vue') },
|
||||||
{ path: 'studio', name: 'Studio', component: () => import('@/views/workbench/StudioPage.vue') },
|
{ path: 'studio', name: 'Studio', component: () => import('@/views/workbench/StudioPage.vue') },
|
||||||
{ path: 'console', name: 'Console', component: () => import('@/views/workbench/ConsolePage.vue') },
|
{ path: 'console', name: 'Console', component: () => import('@/views/workbench/ConsolePage.vue') },
|
||||||
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
|
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
|
||||||
@@ -27,7 +29,8 @@ const routes = [
|
|||||||
{ path: 'report-gen', name: 'ReportGeneration', component: () => import('@/views/report/ReportGenerationPage.vue') },
|
{ path: 'report-gen', name: 'ReportGeneration', component: () => import('@/views/report/ReportGenerationPage.vue') },
|
||||||
{ path: 'tools/document-translate', name: 'DocumentTranslate', component: () => import('@/views/tools/DocumentTranslatePage.vue') },
|
{ path: 'tools/document-translate', name: 'DocumentTranslate', component: () => import('@/views/tools/DocumentTranslatePage.vue') },
|
||||||
{ path: 'tools/copy-proofreading', name: 'CopyProofreading', component: () => import('@/views/tools/CopyProofreadingPage.vue') },
|
{ path: 'tools/copy-proofreading', name: 'CopyProofreading', component: () => import('@/views/tools/CopyProofreadingPage.vue') },
|
||||||
{ path: 'tools/smart-assistant', name: 'SmartAssistant', component: () => import('@/views/tools/SmartAssistantPage.vue') },
|
// Kept as an alias so existing links and bookmarks still land correctly.
|
||||||
|
{ path: 'tools/smart-assistant', name: 'SmartAssistant', redirect: '/home' },
|
||||||
{ path: 'tools/batch-extract', name: 'BatchExtract', component: () => import('@/views/tools/BatchExtractPage.vue') },
|
{ path: 'tools/batch-extract', name: 'BatchExtract', component: () => import('@/views/tools/BatchExtractPage.vue') },
|
||||||
{ path: 'tools/audio-transcribe', name: 'AudioTranscribe', component: () => import('@/views/tools/AudioTranscribePage.vue') },
|
{ path: 'tools/audio-transcribe', name: 'AudioTranscribe', component: () => import('@/views/tools/AudioTranscribePage.vue') },
|
||||||
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
|
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
|
||||||
|
|||||||
@@ -12,10 +12,27 @@ function readPersisted() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智能助手 became the /home surface; the overview moved to /overview.
|
||||||
|
const HOME_TAB = { route: '/home', label: '智能助手', closable: false }
|
||||||
|
|
||||||
|
// Tabs persisted before that move: the old /home tab was 工作台总览, and the
|
||||||
|
// assistant lived at /tools/smart-assistant (now an alias for /home).
|
||||||
|
function normalizeTabs(tabs) {
|
||||||
|
const seen = new Set()
|
||||||
|
const normalized = []
|
||||||
|
for (const tab of tabs || []) {
|
||||||
|
const route = tab.route === '/tools/smart-assistant' ? '/home' : tab.route
|
||||||
|
if (seen.has(route)) continue
|
||||||
|
seen.add(route)
|
||||||
|
normalized.push(route === '/home' ? { ...HOME_TAB } : { ...tab, route })
|
||||||
|
}
|
||||||
|
return normalized.length ? normalized : [{ ...HOME_TAB }]
|
||||||
|
}
|
||||||
|
|
||||||
export const useWorkbenchStore = defineStore('workbench', () => {
|
export const useWorkbenchStore = defineStore('workbench', () => {
|
||||||
const persisted = readPersisted()
|
const persisted = readPersisted()
|
||||||
|
|
||||||
const openTabs = ref(persisted?.openTabs || [{ route: '/home', label: '工作台总览', closable: false }])
|
const openTabs = ref(normalizeTabs(persisted?.openTabs))
|
||||||
const activeBottomTab = ref(persisted?.activeBottomTab || 'timeline')
|
const activeBottomTab = ref(persisted?.activeBottomTab || 'timeline')
|
||||||
const bottomOpen = ref(persisted?.bottomOpen ?? true)
|
const bottomOpen = ref(persisted?.bottomOpen ?? true)
|
||||||
const selectedCases = ref(persisted?.selectedCases || {})
|
const selectedCases = ref(persisted?.selectedCases || {})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* 博昇内部培训平台 — 全局样式 */
|
/* EAI 数字员工平台 — 全局样式 */
|
||||||
/* 参考:docs/08_Design_Rules/DR01_Theme_System.md */
|
/* 参考:docs/08_Design_Rules/DR01_Theme_System.md */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<el-card class="login-card" shadow="always">
|
<el-card class="login-card" shadow="always">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<div class="login-title">博昇AI研究院</div>
|
<div class="login-title">EAI 数字员工平台</div>
|
||||||
<div class="login-subtitle">内部培训平台</div>
|
<div class="login-subtitle">AI Agent Platform</div>
|
||||||
<div class="login-en">EAI-Training</div>
|
<div class="login-en">eai_agentplatform</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<el-dialog v-model="showCert" title="考试合格证书" width="720px" :append-to-body="true">
|
<el-dialog v-model="showCert" title="考试合格证书" width="720px" :append-to-body="true">
|
||||||
<div v-if="cert" class="cert-printable">
|
<div v-if="cert" class="cert-printable">
|
||||||
<div class="cert-border">
|
<div class="cert-border">
|
||||||
<div class="cert-title">博昇AI研究院内部培训平台</div>
|
<div class="cert-title">EAI 数字员工平台</div>
|
||||||
<div class="cert-subtitle">培训合格证书</div>
|
<div class="cert-subtitle">培训合格证书</div>
|
||||||
<div class="cert-body">
|
<div class="cert-body">
|
||||||
<div class="cert-line">兹证明 <span class="cert-name">{{ cert.user_name }}</span></div>
|
<div class="cert-line">兹证明 <span class="cert-name">{{ cert.user_name }}</span></div>
|
||||||
|
|||||||
@@ -643,7 +643,7 @@
|
|||||||
|
|
||||||
<!-- Welcome state -->
|
<!-- Welcome state -->
|
||||||
<div class="welcome-state" id="welcomeState">
|
<div class="welcome-state" id="welcomeState">
|
||||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||||
|
|
||||||
<!-- Category pills -->
|
<!-- Category pills -->
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<!-- Welcome state (shown when no messages) -->
|
<!-- Welcome state (shown when no messages) -->
|
||||||
<template v-if="messages.length <= 1" #welcome>
|
<template v-if="messages.length <= 1" #welcome>
|
||||||
<div class="welcome-content">
|
<div class="welcome-content">
|
||||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
<div class="welcome-title">我 Bosheng 数字员工,我帮你</div>
|
||||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||||
|
|
||||||
<!-- Mode pills above input -->
|
<!-- Mode pills above input -->
|
||||||
@@ -79,11 +79,6 @@
|
|||||||
<div class="card-title">《思考,快与慢》精读笔记整理</div>
|
<div class="card-title">《思考,快与慢》精读笔记整理</div>
|
||||||
<div class="card-meta">合同审查 · 风险评估</div>
|
<div class="card-meta">合同审查 · 风险评估</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="practice-card" @click="fillPractice('帮我生成新产品上市的 GTM 发布计划')">
|
|
||||||
<div class="card-image bg-gtm">🚀</div>
|
|
||||||
<div class="card-title">新产品上市 GTM 发布计划</div>
|
|
||||||
<div class="card-meta">文案生成 · 营销策略</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,15 +431,15 @@ function handleReference() {
|
|||||||
|
|
||||||
.practices-grid {
|
.practices-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.practice-card {
|
.practice-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e8ecf1;
|
border: 1px solid #e8ecf1;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -458,13 +453,14 @@ function handleReference() {
|
|||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
/* 8:3 keeps the full card width but halves the block height (was 4:3). */
|
||||||
border-radius: 8px;
|
aspect-ratio: 8/3;
|
||||||
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28px;
|
font-size: 18px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-image.bg-translate { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
.card-image.bg-translate { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||||
|
|||||||
+116
-11
@@ -14,6 +14,7 @@ set -euo pipefail
|
|||||||
FORCE=0
|
FORCE=0
|
||||||
SKIP_PREFLIGHT=0
|
SKIP_PREFLIGHT=0
|
||||||
SKIP_SMOKE=0
|
SKIP_SMOKE=0
|
||||||
|
SKIP_BUILD=0
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -26,14 +27,20 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--skip-smoke)
|
--skip-smoke)
|
||||||
SKIP_SMOKE=1
|
SKIP_SMOKE=1
|
||||||
;;
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
;;
|
||||||
--help|-h)
|
--help|-h)
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage: ./start_dev_10231_10232.sh [options]
|
Usage: ./start_dev_10231_10232.sh [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--force Kill processes occupying the frontend/backend ports
|
--force Kill whatever holds the ports, even non-project processes
|
||||||
|
(a previous run of this script is always stopped and
|
||||||
|
replaced automatically)
|
||||||
--skip-preflight Skip preflight checks
|
--skip-preflight Skip preflight checks
|
||||||
--skip-smoke Skip admin account smoke login
|
--skip-smoke Skip admin account smoke login
|
||||||
|
--skip-build Do not rebuild the backend, even if sources are newer
|
||||||
--help Show this help
|
--help Show this help
|
||||||
EOF
|
EOF
|
||||||
exit 0
|
exit 0
|
||||||
@@ -46,7 +53,6 @@ EOF
|
|||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
OURS=(eai_agentplatform-server node npm vite)
|
|
||||||
BACKEND_PORT=10232
|
BACKEND_PORT=10232
|
||||||
FRONTEND_PORT=10231
|
FRONTEND_PORT=10231
|
||||||
|
|
||||||
@@ -129,10 +135,58 @@ port_pids() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Decide whether a process belongs to this project.
|
||||||
|
#
|
||||||
|
# This must look at the full command line, not `ps -o comm=`: comm is capped at
|
||||||
|
# 15 characters (the kernel's TASK_COMM_LEN), so the backend reports itself as
|
||||||
|
# "eai_agentplatfo" and could never match its own name. That made the launcher
|
||||||
|
# treat its own backend as a foreign process and refuse to restart.
|
||||||
|
process_is_ours() {
|
||||||
|
local pid="$1"
|
||||||
|
local port="$2"
|
||||||
|
local args
|
||||||
|
args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
|
||||||
|
[[ -z "$args" ]] && return 1
|
||||||
|
[[ "$args" == *"$BACKEND_BIN"* ]] && return 0
|
||||||
|
[[ "$args" == *vite* && "$args" == *"--port $port"* ]] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
process_label() {
|
||||||
|
local pid="$1"
|
||||||
|
local args
|
||||||
|
args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
|
||||||
|
if [[ -z "$args" ]]; then
|
||||||
|
printf 'unknown'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
args="${args%% *}"
|
||||||
|
printf '%s' "${args##*/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop the run recorded by a previous invocation. Uses the pid files so a
|
||||||
|
# previous instance still gets cleaned up even if it is not holding its port.
|
||||||
|
kill_previous_run() {
|
||||||
|
local pid_file="$1"
|
||||||
|
local port="$2"
|
||||||
|
local label="$3"
|
||||||
|
local pid
|
||||||
|
[[ -f "$pid_file" ]] || return 0
|
||||||
|
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||||
|
[[ "$pid" =~ ^[0-9]+$ ]] || return 0
|
||||||
|
kill -0 "$pid" 2>/dev/null || return 0
|
||||||
|
process_is_ours "$pid" "$port" || return 0
|
||||||
|
printf ' [.] stopping previous %s (PID %s)\n' "$label" "$pid"
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
resolve_port() {
|
resolve_port() {
|
||||||
local port="$1"
|
local port="$1"
|
||||||
local label="$2"
|
local label="$2"
|
||||||
local pids raw_comm
|
local pids
|
||||||
mapfile -t pids < <(port_pids "$port" || true)
|
mapfile -t pids < <(port_pids "$port" || true)
|
||||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||||
printf ' [.] %s :%s free\n' "$label" "$port"
|
printf ' [.] %s :%s free\n' "$label" "$port"
|
||||||
@@ -141,20 +195,18 @@ resolve_port() {
|
|||||||
|
|
||||||
printf ' [!] %s :%s in use by ' "$label" "$port"
|
printf ' [!] %s :%s in use by ' "$label" "$port"
|
||||||
for pid in "${pids[@]}"; do
|
for pid in "${pids[@]}"; do
|
||||||
raw_comm="$(ps -p "$pid" -o comm= 2>/dev/null | xargs || echo unknown)"
|
printf '%s(PID %s) ' "$(process_label "$pid")" "$pid"
|
||||||
printf '%s(PID %s) ' "$raw_comm" "$pid"
|
|
||||||
done
|
done
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
|
||||||
for pid in "${pids[@]}"; do
|
for pid in "${pids[@]}"; do
|
||||||
raw_comm="$(ps -p "$pid" -o comm= 2>/dev/null | xargs || echo unknown)"
|
if [[ "$FORCE" -eq 1 ]] || process_is_ours "$pid" "$port"; then
|
||||||
if [[ "$FORCE" -eq 1 || " ${OURS[*]} " == *" $raw_comm "* ]]; then
|
printf ' killing previous %s(PID %s)\n' "$(process_label "$pid")" "$pid"
|
||||||
printf ' killing %s(PID %s)\n' "$raw_comm" "$pid"
|
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
kill -9 "$pid" 2>/dev/null || true
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
printf ' keeping non-project process: %s(PID %s)\n' "$raw_comm" "$pid"
|
printf ' keeping non-project process: %s(PID %s)\n' "$(process_label "$pid")" "$pid"
|
||||||
printf ' fix: stop that process manually, or rerun with --force\n'
|
printf ' fix: stop that process manually, or rerun with --force\n'
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -170,6 +222,40 @@ resolve_port() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# True when the backend binary is missing or older than its sources.
|
||||||
|
#
|
||||||
|
# Preflight only proves the binary exists, which happily launches stale code
|
||||||
|
# after a source edit — the failure is silent, so it needs an explicit check.
|
||||||
|
backend_needs_build() {
|
||||||
|
[[ -f "$BACKEND_BIN" ]] || return 0
|
||||||
|
local newer
|
||||||
|
newer="$(find "$BACKEND_DIR" -name '*.go' -newer "$BACKEND_BIN" -print -quit 2>/dev/null || true)"
|
||||||
|
[[ -n "$newer" ]] && return 0
|
||||||
|
local f
|
||||||
|
for f in "$BACKEND_DIR/go.mod" "$BACKEND_DIR/go.sum"; do
|
||||||
|
[[ -f "$f" && "$f" -nt "$BACKEND_BIN" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
build_backend() {
|
||||||
|
local build_log="$DEBUG_LOG_DIR/backend_build.log"
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
printf ' [FAIL] backend sources changed, but go is not on PATH\n'
|
||||||
|
printf ' fix: install go, or rebuild manually then rerun with --skip-build\n'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf ' [.] backend sources newer than binary, rebuilding ...'
|
||||||
|
if ( cd "$BACKEND_DIR" && go build -o bin/eai_agentplatform-server ./cmd/server ) >"$build_log" 2>&1; then
|
||||||
|
printf ' OK\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf ' FAIL\n'
|
||||||
|
printf ' log : %s\n' "$build_log"
|
||||||
|
tail -n 20 "$build_log" 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_started() {
|
cleanup_started() {
|
||||||
if [[ -f "$BACKEND_PID_FILE" ]]; then
|
if [[ -f "$BACKEND_PID_FILE" ]]; then
|
||||||
kill "$(cat "$BACKEND_PID_FILE")" 2>/dev/null || true
|
kill "$(cat "$BACKEND_PID_FILE")" 2>/dev/null || true
|
||||||
@@ -198,7 +284,6 @@ if [[ "$SKIP_PREFLIGHT" -eq 0 ]]; then
|
|||||||
echo "[1/4] preflight checks"
|
echo "[1/4] preflight checks"
|
||||||
all_ok=1
|
all_ok=1
|
||||||
check "backend dir exists" "run from repo root" test -d "$BACKEND_DIR" || all_ok=0
|
check "backend dir exists" "run from repo root" test -d "$BACKEND_DIR" || all_ok=0
|
||||||
check "backend binary built" "cd backend-go && go build ./cmd/server" test -f "$BACKEND_BIN" || all_ok=0
|
|
||||||
check "frontend dir exists" "run from repo root" test -d "$FRONTEND_DIR" || all_ok=0
|
check "frontend dir exists" "run from repo root" test -d "$FRONTEND_DIR" || all_ok=0
|
||||||
check "frontend node_modules" "run npm install in frontend/" test -e "$FRONTEND_DIR/node_modules" || all_ok=0
|
check "frontend node_modules" "run npm install in frontend/" test -e "$FRONTEND_DIR/node_modules" || all_ok=0
|
||||||
check "node on PATH" "install nodejs" command -v node || all_ok=0
|
check "node on PATH" "install nodejs" command -v node || all_ok=0
|
||||||
@@ -215,7 +300,27 @@ else
|
|||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Runs whether or not preflight did: a stale binary is a silent failure, and
|
||||||
|
# building before the old services are stopped means a compile error leaves the
|
||||||
|
# currently running stack untouched.
|
||||||
|
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||||
|
echo "[1/4] backend build check skipped (--skip-build)"
|
||||||
|
echo
|
||||||
|
elif backend_needs_build; then
|
||||||
|
if ! build_backend; then
|
||||||
|
echo
|
||||||
|
echo "[ABORT] backend build failed; services left as they were."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
echo "[1/4] backend binary up to date"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[2/4] port conflict check"
|
echo "[2/4] port conflict check"
|
||||||
|
kill_previous_run "$BACKEND_PID_FILE" "$BACKEND_PORT" "backend"
|
||||||
|
kill_previous_run "$FRONTEND_PID_FILE" "$FRONTEND_PORT" "frontend"
|
||||||
resolve_port "$BACKEND_PORT" "backend" || exit 1
|
resolve_port "$BACKEND_PORT" "backend" || exit 1
|
||||||
resolve_port "$FRONTEND_PORT" "frontend" || exit 1
|
resolve_port "$FRONTEND_PORT" "frontend" || exit 1
|
||||||
echo
|
echo
|
||||||
@@ -279,7 +384,7 @@ echo "================================================================="
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
echo "Usage: ./start_dev_10231_10232.sh [--force] [--skip-preflight]"
|
echo "Usage: ./start_dev_10231_10232.sh [--force] [--skip-preflight] [--skip-build]"
|
||||||
echo " env: EAI_DEV_BIND_HOST=0.0.0.0 (listen all)"
|
echo " env: EAI_DEV_BIND_HOST=0.0.0.0 (listen all)"
|
||||||
echo " env: EAI_DEV_PUBLIC_HOST=x.x.x.x (public url)"
|
echo " env: EAI_DEV_PUBLIC_HOST=x.x.x.x (public url)"
|
||||||
echo
|
echo
|
||||||
|
|||||||
Reference in New Issue
Block a user