refactor: 后端仓库层收口(A1:课程/产品/素材)+ 收进工作区既有对象化重构

本提交含两部分。第一部分是本轮工作;第二部分是此前一直留在工作区、
从未提交的对象化重构,与第一部分在文件上互相咬合(internal/repository
整个包都是未跟踪状态,且 api 层已有文件引用它),无法拆成两个可编译的提交。

一、仓库层收口 A1 批(本轮工作)

把 api 层手写的 store.DB 查询收进具名仓库方法,只给真正获益的对象做方法,
不机械包裹全量。本批迁移 22 处裸查询(courses.go 9 / media.go 12 / products.go 1),
新增方法:

- MediaFileRepo.ListByBind / ListForAudit / MarkExtracted
- KnowledgeChunkRepo.CountByMediaFile
- ProductRepo.GetVisibleByID

两条业务口径改由仓库单点持有,避免各处手写漂移:
「只有 approved 素材出现在课程详情」与「已停用产品不在课程详情露出」。

修掉两个真实缺陷:
- ProductRepo.GetByID 缺 Where 条件。此前 GET /api/products/{id} 对任意 id 都返回
  第一条产品、对不存在的 id 返回 200,且 PUT /api/products/{id} 会覆盖第一条产品
  —— 数据损坏级。全仓扫描确认这是唯一一处同型写法。
- ProductRepo.Delete 写 status="deleted",而 DELETE 处理器文档与回包都声称
  "inactive",接口在说谎;管理员用 status=all 拉列表会看到前端不认识的状态。
  已对齐为 inactive(与 CourseRepo.Delete 一致)。

删除 8 个零调用且列名不存在的死方法(一调即 SQL 报错):
- media_file 上的 file_path / file_type / approval_status 三列并不存在,
  GetByPath / ListByType / UpdateStatus 全废
- knowledge_chunk 上的 space_id 列不存在(模型早已改为 knowledge_space_key),
  List / Total / ListBySpaceIDs / DeleteBySpace / SearchByVector 全废
取舍边界:能对当前 schema 跑通的死方法保留,跑不通的删或修。

CourseRepo.List 补齐 status=all 档(此前传给它会当作 status='all' 过滤出空列表)。
该方法此前零调用,现与产品列表语义对齐。

验证:go build ./... 与 go test ./... 全绿;另用真实 HTTP 请求验证 34 项
(课程 17 / 产品 3 / 素材 14),跑在数据库副本与独立 KB_DATA_DIR 上,
含 multipart 真上传 → 审批 → pdftotext 提取 → 分片入库的完整链路。

二、此前未提交的对象化重构(非本轮工作)

- 新增 internal/repository 仓库层、connectors、skills、specialists、xapps、jsonutil,
  model/task_record|task_run|task_artifact、api/task_runtime|action_definition|chat_message
- 删除 api/app_definition、connectors、my_app_center、notification、office_skill、
  export_docx|pptx|xlsx、official_account_* 等,随 XApp/Skill/Specialist/Connector
  可插拔打包方向(AR10/AR11)调整
- 资产目录归位:backend-go/knowledge_source → assets/knowledge/source、
  training_materials → assets/training/materials;README 内相对路径同步加深两级;
  deploy env 补 ASSET_ROOT_DIR 并改 KNOWLEDGE_SOURCE_DIR / TRAINING_MATERIALS_DIR
- 前端新增 skills/ specialists/ connectors/ xapps/ 目录与对应页面

验证:前端 npm run build 通过(7.26s)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-19 01:23:51 +08:00
co-authored by Claude Code
parent ddd2d2cbd8
commit 14f303459e
448 changed files with 18792 additions and 17027 deletions
-5
View File
@@ -8,11 +8,6 @@ export function quickAction(actionId, params) {
return http.post('/ai-chat/quick-action', { action_id: actionId, params })
}
// 本人 AI 点数 + 用量(PathCoach 面板数据源)
export function getAiMe() {
return http.get('/ai/me')
}
// 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤)
export function getAiUsage(params) {
return http.get('/ai/usage', { params })
@@ -1,9 +0,0 @@
import http from '@/api/http'
export function getMyAppCenter() {
return http.get('/my/app-center')
}
export function updateMyAppCenter(data) {
return http.put('/my/app-center', data)
}
@@ -1,9 +0,0 @@
import http from './http'
export function listAppDefinitions(params) {
return http.get('/apps', { params })
}
export function getAppDefinitionByKey(key) {
return http.get(`/apps/by-key/${key}`)
}
@@ -1,5 +0,0 @@
import http from './http'
export function sendAssistantChatMessage(data) {
return http.post('/assistant/chat', data)
}
@@ -0,0 +1,5 @@
import http from './http'
export function sendChatMessage(data) {
return http.post('/chat/message', data)
}
+2 -2
View File
@@ -5,8 +5,8 @@ export function listExams() {
return http.get('/exam/list')
}
export function examCover(id) {
return http.get('/exam/cover', { params: { id } })
export function examCover(paperId) {
return http.get('/exam/cover', { params: { paper_id: paperId } })
}
export function examStart(paperId) {
@@ -1,17 +0,0 @@
import http from './http'
export function myNotifications(params) {
return http.get('/notifications', { params })
}
export function unreadCount() {
return http.get('/notifications/unread-count')
}
export function markRead(id) {
return http.put(`/notifications/${id}/read`)
}
export function markAllRead() {
return http.put('/notifications/read-all')
}
@@ -2,21 +2,21 @@ import http from './http'
// 项目:任务的容器。归属由后端按登录用户定,前端不传 owner。
export function listProjects() {
return http.get('/worker/projects')
return http.get('/projects')
}
export function createProject(data = {}) {
return http.post('/worker/projects', data)
return http.post('/projects', data)
}
export function updateProject(id, data) {
return http.put(`/worker/projects/${id}`, data)
return http.put(`/projects/${id}`, data)
}
export function deleteProject(id) {
return http.delete(`/worker/projects/${id}`)
return http.delete(`/projects/${id}`)
}
export function listProjectTasks(id) {
return http.get(`/worker/projects/${id}/tasks`)
return http.get(`/projects/${id}/tasks`)
}
@@ -0,0 +1,54 @@
import http from './http'
export function listTasks(params) {
return http.get('/tasks', { params })
}
// 「我的任务」:跨专员,归属由后端按登录用户定,前端不传 owner。
export function listMyTasks() {
return http.get('/my/tasks')
}
export function createMyTask(data = {}) {
return http.post('/my/tasks', data)
}
export function updateMyTask(id, data) {
return http.put(`/my/tasks/${id}`, data)
}
export function deleteMyTask(id) {
return http.delete(`/my/tasks/${id}`)
}
export function getTaskDetail(id) {
return http.get(`/tasks/${id}`)
}
export function updateTaskStatus(id, data) {
return http.put(`/tasks/${id}/status`, data)
}
export function createTask(data) {
return http.post('/tasks', data)
}
export function updateTask(id, data) {
return http.put(`/tasks/${id}`, data)
}
export function deleteTask(id) {
return http.delete(`/tasks/${id}`)
}
export function getArtifactDetail(id) {
return http.get(`/artifacts/${id}`)
}
export function updateArtifactStatus(id, data) {
return http.put(`/artifacts/${id}/status`, data)
}
export function executeTaskAction(id, data) {
return http.post(`/tasks/${id}/actions`, data)
}
@@ -1,54 +0,0 @@
import http from './http'
export function listWorkerTasks(params) {
return http.get('/worker/tasks', { params })
}
// 「我的任务」:跨专员,归属由后端按登录用户定,前端不传 owner。
export function listMyTasks() {
return http.get('/worker/my-tasks')
}
export function createMyTask(data = {}) {
return http.post('/worker/my-tasks', data)
}
export function updateMyTask(id, data) {
return http.put(`/worker/my-tasks/${id}`, data)
}
export function deleteMyTask(id) {
return http.delete(`/worker/my-tasks/${id}`)
}
export function getWorkerTaskDetail(id) {
return http.get(`/worker/tasks/${id}`)
}
export function updateWorkerTaskStatus(id, data) {
return http.put(`/worker/tasks/${id}/status`, data)
}
export function createWorkerTask(data) {
return http.post('/worker/tasks', data)
}
export function updateWorkerTask(id, data) {
return http.put(`/worker/tasks/${id}`, data)
}
export function deleteWorkerTask(id) {
return http.delete(`/worker/tasks/${id}`)
}
export function getWorkerArtifactDetail(id) {
return http.get(`/worker/artifacts/${id}`)
}
export function updateWorkerArtifactStatus(id, data) {
return http.put(`/worker/artifacts/${id}/status`, data)
}
export function executeWorkerTaskAction(id, data) {
return http.post(`/worker/tasks/${id}/actions`, data)
}
@@ -1,365 +0,0 @@
<template>
<div class="chat-message" :class="[role, { 'has-card': hasCard }]">
<div class="msg-avatar" :class="role">
<slot name="avatar">
{{ role === 'user' ? '👤' : '🤖' }}
</slot>
</div>
<div class="msg-content">
<!-- Text content -->
<div v-if="content" class="msg-text" :class="{ 'user-text': role === 'user' }" v-html="renderedContent"></div>
<!-- Task plan display -->
<div v-if="taskPlan" class="msg-task-plan">
<div class="plan-title">{{ taskPlan.title }}</div>
<div v-for="step in taskPlan.steps" :key="step.id" class="plan-step">
<span class="step-num">{{ step.order }}</span>
<span class="step-title">{{ step.title }}</span>
<span class="step-desc">{{ step.desc }}</span>
</div>
</div>
<!-- File card -->
<div v-if="file" class="msg-file-card">
<div class="file-preview">
<span class="file-icon">{{ file.icon || '📄' }}</span>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-meta">{{ file.meta }}</div>
</div>
<button class="file-action" @click="$emit('fileAction', file)">{{ file.action || '下载' }}</button>
</div>
</div>
<!-- Structured data (table, scores, etc.) -->
<div v-if="structured" class="msg-structured">
<slot name="structured-content">
<pre class="structured-content">{{ structured }}</pre>
</slot>
</div>
<!-- Actions bar -->
<div v-if="showActions" class="msg-actions">
<slot name="actions">
<button v-if="showCopy" @click="$emit('copy')">📋 复制</button>
<button v-if="showDownload" @click="$emit('download')">⬇️ 下载</button>
<button v-if="showContinue" @click="$emit('continue')">🔄 继续</button>
<button v-if="showFollowUp" @click="$emit('followUp')">💬 追问</button>
</slot>
</div>
<!-- Timestamp -->
<div v-if="timestamp" class="msg-time">{{ timestamp }}</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
role: {
type: String,
default: 'user',
validator: (v) => ['user', 'assistant'].includes(v),
},
content: {
type: String,
default: '',
},
taskPlan: {
type: Object,
default: null,
},
file: {
type: Object,
default: null,
},
structured: {
type: String,
default: '',
},
timestamp: {
type: String,
default: '',
},
showActions: {
type: Boolean,
default: true,
},
showCopy: {
type: Boolean,
default: true,
},
showDownload: {
type: Boolean,
default: false,
},
showContinue: {
type: Boolean,
default: false,
},
showFollowUp: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['copy', 'download', 'continue', 'followUp', 'fileAction'])
const renderedContent = computed(() => {
if (!props.content) return ''
// Basic markdown-like rendering
return props.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
})
const hasCard = computed(() => {
return props.file || props.structured
})
</script>
<style scoped>
.chat-message {
display: flex;
gap: 10px;
padding: 4px 0;
width: 100%;
}
.chat-message.user {
flex-direction: row-reverse;
}
.chat-message.has-card {
flex-direction: column;
gap: 8px;
}
.chat-message.has-card .msg-avatar {
align-self: flex-start;
}
.chat-message.has-card .msg-content {
width: 100%;
}
.msg-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.msg-avatar.user {
background: #f5f7fa;
border: 1px solid #e8ecf1;
}
.msg-avatar.assistant {
background: linear-gradient(135deg, #409eff, #63b3ff);
color: #fff;
}
.msg-content {
max-width: 85%;
min-width: 0;
}
.chat-message.user .msg-content {
max-width: 70%;
}
.msg-text {
background: #f5f5f5;
padding: 10px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.user-text {
background: #2b63d9;
color: #fff;
}
.msg-task-plan {
margin-top: 10px;
background: #f0f7ff;
padding: 12px;
border-radius: 10px;
border: 1px solid #d6e8ff;
}
.plan-title {
font-weight: 600;
font-size: 14px;
color: #2b63d9;
margin-bottom: 8px;
}
.plan-step {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.plan-step:last-child {
margin-bottom: 0;
}
.step-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: #2b63d9;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
}
.step-title {
font-weight: 600;
color: #1f2d3d;
}
.step-desc {
color: #6b7785;
}
/* File card */
.msg-file-card {
margin-top: 10px;
}
.file-preview {
display: flex;
align-items: center;
gap: 10px;
background: #f8f9fb;
border: 1px solid #e8ecf1;
border-radius: 10px;
padding: 10px 12px;
}
.file-icon {
font-size: 28px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 13px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.file-action {
padding: 4px 10px;
border-radius: 6px;
background: #409eff;
color: #fff;
border: none;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
}
.file-action:hover {
background: #2b63d9;
}
/* Structured data */
.msg-structured {
margin-top: 10px;
}
.structured-content {
background: #f8f9fb;
border: 1px solid #e8ecf1;
border-radius: 10px;
padding: 14px;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
margin: 0;
}
/* Actions */
.msg-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.msg-actions button {
background: #f8f9fb;
border: 1px solid #e8ecf1;
border-radius: 6px;
padding: 5px 10px;
font-size: 12px;
color: #6b7785;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.15s;
}
.msg-actions button:hover {
background: #eef1f6;
}
.msg-time {
font-size: 11px;
color: #909399;
margin-top: 4px;
padding: 0 4px;
}
/* Mobile responsive */
@media (max-width: 768px) {
.msg-content {
max-width: 90% !important;
}
.msg-actions {
gap: 4px;
}
.msg-actions button {
padding: 4px 8px;
font-size: 11px;
}
}
</style>
@@ -16,10 +16,10 @@
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { staticSkillCatalog } from '@/config/workbench'
import { useSkillCatalogStore } from '@/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/store/specialistCatalog'
import { GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime'
import { getStaticSkillByKey } from '@/skills/core/builtin'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog'
import { GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { openGeneralAssistantInWorkbench, openSpecialistInWorkbench, openSkillInWorkbench } from '@/utils/workbenchNavigation'
const props = defineProps({
@@ -36,15 +36,16 @@ const props = defineProps({
const router = useRouter()
const route = useRoute()
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const skillCatalog = useSkillCatalogStore()
const specialistCatalog = useSpecialistCatalogStore()
const {
currentFocusedObjectKind,
currentFocusedObjectKey,
currentSpecialist,
currentSpecialistKey,
currentSkillKey,
} = storeToRefs(workerRuntime)
} = storeToRefs(taskRuntime)
const runtimeSkillKey = computed(() => props.skillKey || currentSkillKey.value)
const specialistEntry = computed(() =>
@@ -52,11 +53,11 @@ const specialistEntry = computed(() =>
)
const skillEntry = computed(() =>
skillCatalog.getByKey(runtimeSkillKey.value) || staticSkillCatalog.find((skill) => skill.key === runtimeSkillKey.value) || null
skillCatalog.getByKey(runtimeSkillKey.value) || getStaticSkillByKey(runtimeSkillKey.value)
)
const isGeneralAssistant = computed(() => {
if (currentSpecialistKey.value === GENERAL_ASSISTANT_KEY) return true
return currentFocusedObjectKind.value === 'assistant'
return currentFocusedObjectKind.value === 'specialist' && currentFocusedObjectKey.value === GENERAL_ASSISTANT_KEY
})
const kind = computed(() => {
@@ -99,7 +100,7 @@ function openTarget() {
openGeneralAssistantInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
}).catch((error) => {
console.warn('open general assistant target failed', error)
})
@@ -108,7 +109,7 @@ function openTarget() {
openSpecialistInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
specialistKey: currentSpecialistKey.value,
}).catch((error) => {
console.warn('open specialist target failed', error)
@@ -118,7 +119,7 @@ function openTarget() {
openSkillInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
skillKey: runtimeSkillKey.value,
}).catch((error) => {
console.warn('open skill target failed', error)
@@ -1,263 +0,0 @@
<template>
<div class="file-attachment">
<!-- Upload trigger -->
<div
class="upload-trigger"
:class="{ dragover, hasFiles } }"
@drop.prevent="handleDrop"
@dragover.prevent="dragover = true"
@dragleave="dragover = false"
@click="triggerFileInput"
>
<div v-if="!hasFiles" class="upload-placeholder">
<span class="upload-icon">📎</span>
<span class="upload-text">拖放文件到这里,或点击上传</span>
<span class="upload-hint">{{ acceptHint }}</span>
</div>
<div v-else class="file-list">
<div
v-for="(file, idx) in files"
:key="idx"
class="file-item"
>
<span class="file-icon">{{ getFileIcon(file) }}</span>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-size">{{ formatFileSize(file.size) }}</div>
</div>
<button class="file-remove" @click.stop="removeFile(file)">✕</button>
</div>
</div>
</div>
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
:accept="accept"
multiple
style="display:none"
@change="handleFileChange"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
accept: {
type: String,
default: '.txt,.docx,.pdf,.pptx,.xlsx,.doc',
},
maxSize: {
type: Number,
default: 50 * 1024 * 1024, // 50MB
},
maxFiles: {
type: Number,
default: 5,
},
})
const emit = defineEmits(['update:files', 'change'])
const files = ref([])
const dragover = ref(false)
const fileInput = ref(null)
const acceptHint = computed(() => {
const exts = props.accept.split(',').map(e => e.replace('.', '').toUpperCase()).join(', ')
return `支持 ${exts},最大 50MB`
})
const hasFiles = computed(() => files.value.length > 0)
function triggerFileInput() {
fileInput.value?.click()
}
function handleFileChange(event) {
const inputFiles = Array.from(event.target.files)
addFiles(inputFiles)
event.target.value = '' // Reset for re-upload same file
}
function handleDrop(event) {
dragover.value = false
const droppedFiles = Array.from(event.dataTransfer.files)
addFiles(droppedFiles)
}
function addFiles(newFiles) {
// Filter by size
const validFiles = newFiles.filter(f => f.size <= props.maxSize)
// Update
files.value = [...files.value, ...validFiles].slice(0, props.maxFiles)
// Notify
emit('change', files.value)
emit('update:files', files.value)
}
function removeFile(file) {
files.value = files.value.filter(f => f !== file)
emit('change', files.value)
emit('update:files', files.value)
}
function getFileIcon(file) {
const ext = file.name.split('.').pop().toLowerCase()
const icons = {
txt: '📝',
docx: '📄',
doc: '📄',
pdf: '📑',
pptx: '📊',
xlsx: '📈',
csv: '📊',
mp3: '🎵',
wav: '🎵',
m4a: '🎵',
}
return icons[ext] || '📄'
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
}
function clearFiles() {
files.value = []
emit('change', [])
emit('update:files', [])
}
defineExpose({ clearFiles, files })
</script>
<style scoped>
.file-attachment {
width: 100%;
}
.upload-trigger {
border: 2px dashed #e0e6ed;
border-radius: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
background: #fafbfc;
}
.upload-trigger:hover {
border-color: #409eff;
background: #f0f7ff;
}
.upload-trigger.dragover {
border-color: #409eff;
background: #e1effe;
box-shadow: 0 0 0 3px rgba(64,158,255,0.1);
}
.upload-trigger.has-files {
border: none;
background: transparent;
padding: 0;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.upload-icon {
font-size: 24px;
}
.upload-text {
font-size: 13px;
color: #606266;
}
.upload-hint {
font-size: 11px;
color: #909399;
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
background: #f8f9fb;
border: 1px solid #e8ecf1;
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
max-width: 200px;
}
.file-icon {
font-size: 18px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.file-name {
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #909399;
font-size: 11px;
}
.file-remove {
width: 20px;
height: 20px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: #909399;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.file-remove:hover {
background: #eef1f6;
color: #606266;
}
/* Mobile */
@media (max-width: 768px) {
.file-item {
max-width: 160px;
}
}
</style>
@@ -1,370 +0,0 @@
<template>
<div class="history-sidebar" :class="{ collapsed }">
<div class="sidebar-top">
<div class="search-box">
<span class="search-icon">🔍</span>
<input
v-model="searchQuery"
class="search-input"
placeholder="搜索对话..."
@input="$emit('search', searchQuery)"
/>
</div>
<button class="new-chat-btn" @click="$emit('newChat')">
<span class="btn-icon">+</span>
<span class="btn-text" :class="{ 'hide': collapsed }">新建</span>
</button>
</div>
<!-- Time-grouped history -->
<div class="history-groups">
<div
v-for="group in groupedHistory"
:key="group.key"
class="history-group"
:class="{ collapsed: group._collapsed }"
>
<div class="group-header" @click="group._collapsed = !group._collapsed">
<span class="group-title">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="group-items">
<div
v-for="item in group.items"
:key="item.id"
class="history-item"
:class="{ active: item.id === activeId }"
@click="$emit('select', item.id)"
>
<span class="item-icon">{{ item.toolIcon || '💬' }}</span>
<div class="item-info">
<div class="item-title">{{ item.title }}</div>
<div class="item-preview">{{ item.preview }}</div>
</div>
<div class="item-actions" @click.stop>
<button class="action-btn rename" title="重命名" @click="$emit('rename', item)">✏️</button>
<button class="action-btn delete" title="删除" @click="$emit('delete', item)">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
activeId: {
type: String,
default: '',
},
collapsed: {
type: Boolean,
default: false,
},
historyItems: {
type: Array,
default: () => [],
},
toolIcons: {
type: Object,
default: () => ({
'smart-assistant': '💬',
'document-translate': '📝',
'copy-proofreading': '✏️',
'audio-transcribe': '🎤',
'batch-extract': '📊',
'contract-review': '📋',
}),
},
})
const emit = defineEmits([
'update:modelValue',
'newChat',
'select',
'rename',
'delete',
'search',
])
const searchQuery = ref('')
// Group history by time period
const groupedHistory = computed(() => {
const items = props.historyItems
if (!items.length) return []
const groups = {
'今天': [],
'昨天': [],
'最近': [],
'更早': [],
}
items.forEach((item) => {
const today = new Date()
const created = new Date(item.created_at || Date.now())
const diffDays = Math.floor((today - created) / (1000 * 60 * 60 * 24))
let groupKey
if (diffDays === 0) groupKey = '今天'
else if (diffDays === 1) groupKey = '昨天'
else if (diffDays <= 7) groupKey = '最近'
else groupKey = '更早'
groups[groupKey]?.push(item) || (groups[groupKey] = [item])
})
return Object.entries(groups)
.filter(([, items]) => items.length)
.map(([label, items]) => ({
key: label,
label,
items: items.map((item) => ({
...item,
toolIcon: props.toolIcons[item.tool_key] || '💬',
_collapsed: false,
})),
}))
})
</script>
<style scoped>
.history-sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #eef1f6;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.2s;
}
.history-sidebar.collapsed {
width: 240px;
}
/* Top bar */
.sidebar-top {
padding: 12px;
border-bottom: 1px solid #eef1f6;
display: flex;
flex-direction: column;
gap: 8px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: #f8f9fb;
border: 1px solid #e8ecf1;
border-radius: 8px;
padding: 0 10px;
}
.search-icon {
font-size: 14px;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: #303133;
padding: 8px 0;
font-family: inherit;
}
.search-input::placeholder {
color: #c0c8d4;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
background: linear-gradient(135deg, #409eff 0%, #2b63d9 100%);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.2s;
}
.new-chat-btn:hover {
box-shadow: 0 2px 8px rgba(64,158,255,0.35);
}
.btn-icon {
font-size: 16px;
font-weight: 700;
}
.btn-text {
overflow: hidden;
white-space: nowrap;
}
.btn-text.hide {
display: none;
}
/* History groups */
.history-groups {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.history-group {
border-bottom: 1px solid #f0f2f6;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
cursor: pointer;
user-select: none;
}
.group-title {
font-size: 12px;
font-weight: 700;
color: #909399;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.group-count {
font-size: 11px;
color: #c0c8d4;
}
.group-items {
padding: 0 8px 4px;
}
.history-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
position: relative;
}
.history-item:hover {
background: #f5f7fa;
}
.history-item.active {
background: #e1effe;
}
.item-icon {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
}
.item-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.item-title {
font-size: 13px;
color: #303133;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item.active .item-title {
color: #409eff;
font-weight: 600;
}
.item-preview {
font-size: 11px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.item-actions {
opacity: 0;
display: flex;
gap: 2px;
transition: opacity 0.15s;
}
.history-item:hover .item-actions {
opacity: 1;
}
.action-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.action-btn:hover {
background: rgba(0,0,0,0.06);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d0d7e2;
border-radius: 2px;
}
/* Mobile */
@media (max-width: 768px) {
.history-sidebar {
width: 240px;
}
.item-actions {
opacity: 1;
}
}
</style>
@@ -121,12 +121,12 @@ import { ElMessageBox } from 'element-plus'
import {
ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting, UploadFilled,
} from '@element-plus/icons-vue'
import { staticSkillCatalog } from '@/config/workbench'
import { listConnectors } from '@/api/connector'
import { getStaticSkillByKey } from '@/skills/core/builtin'
import { listConnectors } from '@/connectors/api/connector'
import { useAuthStore } from '@/store/auth'
import { useSkillCatalogStore } from '@/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/store/specialistCatalog'
import { GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog'
import { GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { openGeneralAssistantInWorkbench, openSkillInWorkbench, openSpecialistInWorkbench } from '@/utils/workbenchNavigation'
const props = defineProps({
@@ -160,7 +160,7 @@ const router = useRouter()
const auth = useAuthStore()
const skillCatalog = useSkillCatalogStore()
const specialistCatalog = useSpecialistCatalogStore()
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const isAdmin = computed(() => auth.isAdmin)
const open = ref(false)
const keyword = ref('')
@@ -180,7 +180,7 @@ function routable(path) {
}
const generalAssistantItem = computed(() => {
const item = skillCatalog.getByKey('smart-assistant') || staticSkillCatalog.find((skill) => skill.key === 'smart-assistant')
const item = skillCatalog.getByKey('smart-assistant') || getStaticSkillByKey('smart-assistant')
if (!item) return null
return {
key: GENERAL_ASSISTANT_KEY,
@@ -214,15 +214,15 @@ const entries = computed(() => {
items: [
...(generalAssistantItem.value ? [generalAssistantItem.value] : []),
...specialistCatalog.specialists
.filter((app) => routable(app.objectEntryRoute))
.filter((specialist) => routable(specialist.objectEntryRoute))
// key 要在切换时用:选了专员就等于把当前任务改挂到它名下。
.map((app) => ({
key: app.key,
label: app.label,
caption: app.summary,
display_code: app.display_code,
eailogic_code: app.eailogic_code,
objectEntryRoute: app.objectEntryRoute,
.map((specialist) => ({
key: specialist.key,
label: specialist.label,
caption: specialist.summary,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
objectEntryRoute: specialist.objectEntryRoute,
})),
],
manage: { label: '专员市场', pageRoute: '/market' },
@@ -426,7 +426,7 @@ async function pickItem(item) {
openGeneralAssistantInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
}).catch((error) => {
console.warn('switch to general assistant failed', error)
})
@@ -435,7 +435,7 @@ async function pickItem(item) {
openSpecialistInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
specialistKey: item.key,
}).catch((error) => {
console.warn('attach specialist to current task failed', error)
@@ -446,7 +446,7 @@ async function pickItem(item) {
openSkillInWorkbench({
router,
currentPath: route.path,
workerRuntime,
taskRuntime,
skillKey: item.key,
}).catch((error) => {
console.warn('attach skill to current task failed', error)
@@ -75,11 +75,11 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { settingsForSkill } from '@/config/skillSettings'
import { useSkillCatalogStore } from '@/store/skillCatalog'
import { useSavedFlash, useSkillSettingsStore } from '@/store/skillSettings'
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
import { buildActionRecords, markFlowState } from '@/utils/specialistFlow'
import { settingsForSkill } from '@/skills/config/skillSettings'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { useSavedFlash, useSkillSettingsStore } from '@/skills/store/skillSettings'
import { useTaskRuntimeStore } from '@/store/taskRuntime'
import { buildActionRecords, markFlowState } from '@/specialists/runtime/specialistFlow'
// Phase 2 起,顶上这条直接读任务上下文里的 currentSkillKey。
// 传 prop 只是为了局部复用;不传时直接跟着当前任务的技能走。
@@ -89,14 +89,14 @@ const props = defineProps({
const settings = useSkillSettingsStore()
const skillCatalog = useSkillCatalogStore()
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const { saving, mark } = useSavedFlash()
const open = ref(false)
const skillKey = computed(() => {
if (props.skillKey) return props.skillKey
return workerRuntime.currentSkillKey || 'smart-assistant'
return taskRuntime.currentSkillKey || 'smart-assistant'
})
const skill = computed(() => skillCatalog.getByKey(skillKey.value) || null)
@@ -126,21 +126,21 @@ const summary = computed(() => {
})
// ---- 阶段:和右栏同一套推断 ----
// 步骤表来自当前任务挂着的专员(buildActionRecords),跑过没有看 worker_run。
// 步骤表来自当前任务挂着的专员(buildActionRecords),跑过没有看 task_run。
// 通用助手这类没挂专员的,步骤表为空 —— 那就没有流程可说,点只能是灰的。
const detail = computed(() => workerRuntime.currentTaskDetail)
const task = computed(() => detail.value?.task || workerRuntime.currentTask || null)
const detail = computed(() => taskRuntime.currentTaskDetail)
const task = computed(() => detail.value?.task || taskRuntime.currentTask || null)
const steps = computed(() =>
markFlowState(
buildActionRecords({
specialist: workerRuntime.currentSpecialist,
specialist: taskRuntime.currentSpecialist,
runs: detail.value?.runs || [],
}),
task.value,
),
)
const doneCount = computed(() => steps.value.filter((s) => s.flowState === 'done').length)
// 蓝点是**推断**出来的,不是后端上报的:worker_run.status 恒为 "done",
// 蓝点是**推断**出来的,不是后端上报的:task_run.status 恒为 "done",
// 没有任何字段能表达「进行中」(见 utils/specialistFlow.js 顶部的说明)。
// 所以徽章上必须带「推断」两个字,不能让它看起来像进度事实。
const hasInferred = computed(() => steps.value.some((s) => s.flowInferred))
@@ -1,118 +0,0 @@
<template>
<!--
当前专员的一枚小胶囊,挂在输入框左下角那颗 + 的右边。
它只负责「我现在是在跟谁说话」这一件事,点一下去这个专员自己的工作台。
选专员仍然只在 + 菜单里 —— 那里是唯一的入口,这是输入框这层已经定下的
规矩(见 ChatInputBar 里那行注释),这里再放一个选择器就等于有两个入口,
两边还会不同步。
没有专员时整枚不出现。落在通用助手名下的任务也不出现:那条记录是
「还没选专员」的兜底,把它当专员显示出来会让人以为选过了。
-->
<button
v-if="visible"
class="specialist-chip"
type="button"
:title="hint"
@click="openWorkbench"
>
<span class="chip-dot" aria-hidden="true">专</span>
<span class="chip-label">{{ label }}</span>
</button>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSpecialistCatalogStore } from '@/store/specialistCatalog'
import { GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime'
import { openSpecialistInWorkbench } from '@/utils/workbenchNavigation'
const route = useRoute()
const router = useRouter()
const workerRuntime = useWorkerRuntimeStore()
const specialistCatalog = useSpecialistCatalogStore()
// 专员目录里的配置。objectEntryRoute 要从这儿取,store 里那份是接口返回的,可能还没到。
const catalogEntry = computed(() =>
specialistCatalog.getByKey(workerRuntime.currentSpecialistKey)
)
const visible = computed(() => {
const key = workerRuntime.currentSpecialistKey
return Boolean(key) && key !== GENERAL_ASSISTANT_KEY
})
// 优先用接口返回的 label(专员改了名这里跟着变),接口还没回来时退回配置里的,
// 免得胶囊先显示空白再跳字。
const label = computed(
() => workerRuntime.currentSpecialist?.label || catalogEntry.value?.label || '已选专员'
)
const hint = computed(() =>
catalogEntry.value ? `当前专员:${label.value} · 点开它的工作台` : `当前专员:${label.value}`
)
// 点胶囊去这个专员的完整工作台。专员目录里没有的(比如市场里装了一半的),
// 就只当个标签,不做没把握的跳转。
function openWorkbench() {
openSpecialistInWorkbench({
router,
currentPath: route.path,
workerRuntime,
specialistKey: workerRuntime.currentSpecialistKey,
}).catch((error) => {
console.warn('open specialist chip failed', error)
})
}
onMounted(() => {
specialistCatalog.hydrate()
})
</script>
<style scoped>
.specialist-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
max-width: 200px;
padding: 0 10px 0 4px;
border: 1px solid #e0e6ed;
border-radius: 14px;
background: #fff;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #303133;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.specialist-chip:hover {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
}
.chip-dot {
width: 20px;
height: 20px;
flex: none;
border-radius: 50%;
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
color: #fff;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
}
.chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -3,10 +3,10 @@
对话右栏:当前专员本次任务的工作流与产物。
数据全部来自后端 —— 步骤是专员的 action_records_json,产物是这条任务的
worker_artifact,两边靠 action_key / created_by_run_id 对接。没有任何一步
task_artifact,两边靠 action_key / created_by_run_id 对接。没有任何一步
是照着样张编出来的。
一件事必须写在最前面:**「进行中」那个蓝框是前端推断的**。后端 worker_run
一件事必须写在最前面:**「进行中」那个蓝框是前端推断的**。后端 task_run
的 status 写进去时恒为 "done",没有任何字段能表达「正在跑哪一步」。所以要
靠推断(见 utils/specialistFlow.js 的 markFlowState),并且推断出来的那一步
在界面上要标出来,不能让它冒充实测进度。
@@ -155,13 +155,13 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime'
import { useSkillCatalogStore } from '@/store/skillCatalog'
import { artifactsOfStep, buildActionRecords, buildResultRecords, markFlowState } from '@/utils/specialistFlow'
import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { artifactsOfStep, buildActionRecords, buildResultRecords, markFlowState } from '@/specialists/runtime/specialistFlow'
const emit = defineEmits(['collapse'])
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const skillCatalog = useSkillCatalogStore()
const {
currentFocusedObjectKey,
@@ -172,7 +172,7 @@ const {
currentTask,
currentTaskDetail,
currentTaskId,
} = storeToRefs(workerRuntime)
} = storeToRefs(taskRuntime)
const tab = ref('flow')
const specialist = computed(() => currentSpecialist.value)
@@ -241,7 +241,7 @@ const steps = computed(() =>
}))
)
// liveOnly:产物 tab 里每一条都必须是这条任务真的产出的 worker_artifact。
// liveOnly:产物 tab 里每一条都必须是这条任务真的产出的 task_artifact。
// 不退回种子里的 result_records_json(那是「会产出什么」,而且混着 skill 记录),
// 也不退回工作台快照的文案(那是演示数据)。没有就显示空态。
const results = computed(() =>
@@ -469,9 +469,9 @@ function formatFileSize(value) {
// force 是必要的:同一专员换了任务后 runs 变了,但专员记录本身没变。
async function refresh() {
const key = currentSpecialistKey.value
if (key && key !== GENERAL_ASSISTANT_KEY) await workerRuntime.loadSpecialist(key)
if (key && key !== GENERAL_ASSISTANT_KEY) await taskRuntime.loadSpecialist(key)
const taskId = currentTaskId.value
if (taskId) await workerRuntime.loadTaskDetail(taskId)
if (taskId) await taskRuntime.loadTaskDetail(taskId)
}
onMounted(async () => {
@@ -1,20 +1,20 @@
<template>
<aside class="app-chat-rail">
<div class="app-chat-rail__head">
<div class="app-chat-rail__title">{{ title }}</div>
<div v-if="subtitle" class="app-chat-rail__subtitle">{{ subtitle }}</div>
<aside class="surface-chat-rail">
<div class="surface-chat-rail__head">
<div class="surface-chat-rail__title">{{ title }}</div>
<div v-if="subtitle" class="surface-chat-rail__subtitle">{{ subtitle }}</div>
</div>
<div ref="messageListRef" class="app-chat-rail__messages">
<div v-if="!activeMessages.length" class="app-chat-rail__empty">
<div class="app-chat-rail__empty-title">右侧协作已接入</div>
<div class="app-chat-rail__empty-text">{{ emptyText }}</div>
<div v-if="starterPrompts.length" class="app-chat-rail__prompts">
<div ref="messageListRef" class="surface-chat-rail__messages">
<div v-if="!activeMessages.length" class="surface-chat-rail__empty">
<div class="surface-chat-rail__empty-title">右侧协作已接入</div>
<div class="surface-chat-rail__empty-text">{{ emptyText }}</div>
<div v-if="starterPrompts.length" class="surface-chat-rail__prompts">
<button
v-for="prompt in starterPrompts"
:key="prompt"
type="button"
class="app-chat-rail__prompt"
class="surface-chat-rail__prompt"
@click="handlePrompt(prompt)"
>
{{ prompt }}
@@ -25,25 +25,25 @@
<div
v-for="message in activeMessages"
:key="message.id"
class="app-chat-rail__message"
class="surface-chat-rail__message"
:class="message.role"
>
<div class="app-chat-rail__avatar">{{ message.role === 'user' ? '我' : 'AI' }}</div>
<div class="app-chat-rail__bubble-wrap">
<div class="app-chat-rail__bubble">{{ message.content }}</div>
<div class="app-chat-rail__time">{{ message.timestamp }}</div>
<div class="surface-chat-rail__avatar">{{ message.role === 'user' ? '我' : 'AI' }}</div>
<div class="surface-chat-rail__bubble-wrap">
<div class="surface-chat-rail__bubble">{{ message.content }}</div>
<div class="surface-chat-rail__time">{{ message.timestamp }}</div>
</div>
</div>
<div v-if="sending" class="app-chat-rail__message assistant">
<div class="app-chat-rail__avatar">AI</div>
<div class="app-chat-rail__bubble-wrap">
<div class="app-chat-rail__bubble app-chat-rail__bubble--loading">正在整理回答...</div>
<div v-if="sending" class="surface-chat-rail__message assistant">
<div class="surface-chat-rail__avatar">AI</div>
<div class="surface-chat-rail__bubble-wrap">
<div class="surface-chat-rail__bubble surface-chat-rail__bubble--loading">正在整理回答...</div>
</div>
</div>
</div>
<div class="app-chat-rail__composer">
<div class="surface-chat-rail__composer">
<el-input
v-model="draft"
type="textarea"
@@ -52,7 +52,7 @@
:placeholder="placeholder"
@keydown.enter.exact.prevent="send"
/>
<div class="app-chat-rail__actions">
<div class="surface-chat-rail__actions">
<LlmRoutePicker compact />
<el-button type="primary" :loading="sending" :disabled="!draft.trim()" @click="send">
发送
@@ -65,14 +65,14 @@
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { sendAssistantChatMessage } from '@/api/chat'
import { sendChatMessage } from '@/api/chatMessage'
import LlmRoutePicker from '@/components/chat/LlmRoutePicker.vue'
import { useAiRouteStore } from '@/store/llmRoute'
const props = defineProps({
scopeKey: {
type: String,
default: 'app',
default: 'surface',
},
title: {
type: String,
@@ -98,7 +98,7 @@ const props = defineProps({
const aiRouteStore = useAiRouteStore()
const threadMap = ref({})
const activeScopeKey = ref('app')
const activeScopeKey = ref('surface')
const messageListRef = ref(null)
function createThread() {
@@ -110,7 +110,7 @@ function createThread() {
}
function ensureThread(key) {
const scope = String(key || 'app')
const scope = String(key || 'surface')
if (!threadMap.value[scope]) {
threadMap.value = {
...threadMap.value,
@@ -123,7 +123,7 @@ function ensureThread(key) {
watch(
() => props.scopeKey,
(key) => {
activeScopeKey.value = String(key || 'app')
activeScopeKey.value = String(key || 'surface')
ensureThread(activeScopeKey.value)
},
{ immediate: true }
@@ -186,7 +186,7 @@ async function send() {
try {
await aiRouteStore.ensureAiChatRoutes()
const res = await sendAssistantChatMessage({
const res = await sendChatMessage({
message: text,
mode: 'quick',
ai_route_id: aiRouteStore.selectedAiChatRouteId,
@@ -207,7 +207,7 @@ async function send() {
</script>
<style scoped>
.app-chat-rail {
.surface-chat-rail {
width: 360px;
min-width: 360px;
display: flex;
@@ -219,26 +219,26 @@ async function send() {
overflow: hidden;
}
.app-chat-rail__head {
.surface-chat-rail__head {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-chat-rail__title {
.surface-chat-rail__title {
font-size: 14px;
font-weight: 700;
color: #1f2d3d;
}
.app-chat-rail__subtitle {
.surface-chat-rail__subtitle {
font-size: 12px;
line-height: 1.5;
color: #909399;
}
.app-chat-rail__messages {
.surface-chat-rail__messages {
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -248,32 +248,32 @@ async function send() {
padding-right: 2px;
}
.app-chat-rail__empty {
.surface-chat-rail__empty {
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px 2px 8px;
}
.app-chat-rail__empty-title {
.surface-chat-rail__empty-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.app-chat-rail__empty-text {
.surface-chat-rail__empty-text {
font-size: 12px;
line-height: 1.6;
color: #909399;
}
.app-chat-rail__prompts {
.surface-chat-rail__prompts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-chat-rail__prompt {
.surface-chat-rail__prompt {
padding: 6px 10px;
border: 1px solid #e3e8ef;
border-radius: 999px;
@@ -284,23 +284,23 @@ async function send() {
cursor: pointer;
}
.app-chat-rail__prompt:hover {
.surface-chat-rail__prompt:hover {
border-color: #cfe0ff;
background: #f7fbff;
color: #315fc2;
}
.app-chat-rail__message {
.surface-chat-rail__message {
display: flex;
gap: 8px;
align-items: flex-start;
}
.app-chat-rail__message.user {
.surface-chat-rail__message.user {
flex-direction: row-reverse;
}
.app-chat-rail__avatar {
.surface-chat-rail__avatar {
width: 24px;
height: 24px;
border-radius: 50%;
@@ -314,23 +314,23 @@ async function send() {
flex: none;
}
.app-chat-rail__message.user .app-chat-rail__avatar {
.surface-chat-rail__message.user .surface-chat-rail__avatar {
background: #d9f2e6;
color: #059669;
}
.app-chat-rail__bubble-wrap {
.surface-chat-rail__bubble-wrap {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-chat-rail__message.user .app-chat-rail__bubble-wrap {
.surface-chat-rail__message.user .surface-chat-rail__bubble-wrap {
align-items: flex-end;
}
.app-chat-rail__bubble {
.surface-chat-rail__bubble {
max-width: 100%;
padding: 9px 11px;
border-radius: 14px;
@@ -342,20 +342,20 @@ async function send() {
word-break: break-word;
}
.app-chat-rail__message.user .app-chat-rail__bubble {
.surface-chat-rail__message.user .surface-chat-rail__bubble {
background: #e8f3ff;
}
.app-chat-rail__bubble--loading {
.surface-chat-rail__bubble--loading {
color: #909399;
}
.app-chat-rail__time {
.surface-chat-rail__time {
font-size: 11px;
color: #b0b7c3;
}
.app-chat-rail__composer {
.surface-chat-rail__composer {
flex: none;
display: flex;
flex-direction: column;
@@ -363,7 +363,7 @@ async function send() {
padding-top: 2px;
}
.app-chat-rail__actions {
.surface-chat-rail__actions {
display: flex;
align-items: center;
justify-content: space-between;
@@ -371,14 +371,14 @@ async function send() {
}
@media (max-width: 1280px) {
.app-chat-rail {
.surface-chat-rail {
width: 320px;
min-width: 320px;
}
}
@media (max-width: 1100px) {
.app-chat-rail {
.surface-chat-rail {
display: none;
}
}
+12 -7
View File
@@ -5,6 +5,11 @@
// 现在把它收平:4 个 tab(专员 / 技能 / APP / 连接器)都只是“浏览配置”的目录页入口,
// 不再在这一层承担“进入可执行页面”的职责。
import { Avatar, CirclePlus, Collection, Grid, More, Share } from '@element-plus/icons-vue'
import {
INTERNAL_EXAM_XAPP_ROOT,
INTERNAL_TRAINING_XAPP_ROOT,
XAPP_DIRECTORY_ROUTE,
} from '@/xapps/config/xappRoutes'
export const navSections = [
{
@@ -24,7 +29,7 @@ export const navSections = [
items: [{ label: '我的项目', pageRoute: '/projects' }],
},
{
key: 'ai-capability',
key: 'object-catalog',
label: '3. 专员·技能·APP·连接器',
icon: Avatar,
// 这一项现在只负责“看目录 / 看配置”,不直接跳进使用页。
@@ -32,18 +37,18 @@ export const navSections = [
items: [
{ label: '专员', pageRoute: '/catalog/specialists' },
{ label: '技能', pageRoute: '/catalog/skills' },
{ label: 'APP', pageRoute: '/catalog/apps' },
{ label: 'APP', pageRoute: '/catalog/xapps' },
{ label: '连接器', pageRoute: '/catalog/connectors' },
],
},
{
key: 'long-apps',
label: '4. 长程APP',
key: 'xapps',
label: '4. APP',
icon: Grid,
items: [
{ label: 'APP目录', pageRoute: '/long-apps' },
{ label: '内部培训APP', pageRoute: '/courses' },
{ label: '内部考试APP', pageRoute: '/exam/self-test' },
{ label: 'APP目录', pageRoute: XAPP_DIRECTORY_ROUTE },
{ label: '内部培训APP', pageRoute: INTERNAL_TRAINING_XAPP_ROOT },
{ label: '内部考试APP', pageRoute: INTERNAL_EXAM_XAPP_ROOT },
],
},
{
@@ -13,7 +13,7 @@ import {
Promotion,
Reading,
} from '@element-plus/icons-vue'
import { staticSkillCatalog } from '@/config/workbench'
import { staticSkillCatalog } from '@/skills/core/builtin'
export const SKILL_KEY_LABELS = Object.fromEntries(
staticSkillCatalog.map((item) => [item.key, item.label])
@@ -105,7 +105,7 @@ export function findProjectTemplate(id) {
}
// 新建项目弹窗里那个「项目模板」下拉的默认项。选中它 = 不套模板,
// 指令和三个能力列表都由用户自己填。
// 指令和三个对象列表都由用户自己填。
//
// 值不能是空串:el-select 把空串当成「什么都没选」,下拉框会显示成空白
// 而不是「自定义」。提交时再换回空串给后端(后端只认「有没有存过模板」)。
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import http from './http'
import http from '@/api/http'
export function listConnectors() {
return http.get('/connectors')
@@ -279,14 +279,14 @@
</main>
</div>
<AppChatRail
v-if="appChatContext"
:scope-key="appChatContext.scopeKey"
:title="appChatContext.title"
:subtitle="appChatContext.subtitle"
:placeholder="appChatContext.placeholder"
:empty-text="appChatContext.emptyText"
:starter-prompts="appChatContext.starterPrompts"
<SurfaceChatRail
v-if="surfaceChatContext"
:scope-key="surfaceChatContext.scopeKey"
:title="surfaceChatContext.title"
:subtitle="surfaceChatContext.subtitle"
:placeholder="surfaceChatContext.placeholder"
:empty-text="surfaceChatContext.emptyText"
:starter-prompts="surfaceChatContext.starterPrompts"
/>
<NavSearch v-model="searchOpen" :sections="visibleSections" :tabs="openTabs" />
@@ -348,17 +348,23 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Check, Delete, EditPen, Expand, Fold, FolderAdd, MoreFilled, Search, Top } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppChatRail from '@/components/chat/AppChatRail.vue'
import SurfaceChatRail from '@/components/chat/SurfaceChatRail.vue'
import SectionTabs from '@/components/SectionTabs.vue'
import NavSearch from '@/components/NavSearch.vue'
import { useAuthStore } from '@/store/auth'
import { useProjectStore } from '@/store/projectStore'
import { useSkillCatalogStore } from '@/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/store/specialistCatalog'
import { useProjectStore } from '@/store/project'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog'
import { useWorkbenchStore } from '@/store/workbench'
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
import { getAppCases, getDefaultCaseId } from '@/config/workbench'
import { useTaskRuntimeStore } from '@/store/taskRuntime'
import { getDefaultWorkspaceCaseId, getWorkspaceCases } from '@/config/workbench'
import { relativeTime } from '@/config/projectTemplates'
import {
INTERNAL_EXAM_XAPP_ROOT,
INTERNAL_TRAINING_MODULE_ROUTES,
INTERNAL_TRAINING_XAPP_ROOT,
XAPP_DIRECTORY_ROUTE,
} from '@/xapps/config/xappRoutes'
import {
findGroupByPath,
findSectionByPath,
@@ -373,7 +379,7 @@ const projectStore = useProjectStore()
const skillCatalog = useSkillCatalogStore()
const specialistCatalog = useSpecialistCatalogStore()
const workbench = useWorkbenchStore()
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const isAdmin = computed(() => auth.isAdmin)
const username = computed(() => auth.username || '同事')
@@ -410,11 +416,11 @@ const activeGroup = computed(() => findGroupByPath(activeSection.value, route.pa
const openTabs = computed(() => workbench.openTabs)
// 侧边栏那一列「最近任务」。归属由后端按登录用户定,这里只负责显示。
const myTasks = computed(() => workerRuntime.myTasks)
const loadingMyTasks = computed(() => workerRuntime.loadingMyTasks)
const currentTaskId = computed(() => workerRuntime.currentTaskId)
const currentTask = computed(() => workerRuntime.currentTask)
const pendingTaskTitle = computed(() => String(workerRuntime.pendingTaskTitle || '').trim())
const myTasks = computed(() => taskRuntime.myTasks)
const loadingMyTasks = computed(() => taskRuntime.loadingMyTasks)
const currentTaskId = computed(() => taskRuntime.currentTaskId)
const currentTask = computed(() => taskRuntime.currentTask)
const pendingTaskTitle = computed(() => String(taskRuntime.pendingTaskTitle || '').trim())
const projectOptions = computed(() => projectStore.projects)
const collapsedProjectMap = ref({})
const currentTaskTitle = computed(() => (
@@ -425,15 +431,13 @@ const currentTaskDisplayTitle = computed(() =>
)
const shouldShowTaskTitlebar = computed(() => {
const path = route.path || ''
if (path === '/long-apps') return false
if (path.startsWith('/courses')) return false
if (path.startsWith('/products')) return false
if (path.startsWith('/company-train')) return false
if (path.startsWith('/exam')) return false
if (path === XAPP_DIRECTORY_ROUTE) return false
if (path.startsWith(INTERNAL_TRAINING_XAPP_ROOT)) return false
if (path.startsWith(INTERNAL_EXAM_XAPP_ROOT)) return false
if (path === '/knowledge-hub') return false
return true
})
const appChatContext = computed(() => resolveAppChatContext(route.path || ''))
const surfaceChatContext = computed(() => resolveSurfaceChatContext(route.path || ''))
const recentLooseTasks = computed(() => myTasks.value.filter((task) => !task?.project_id))
const recentProjectGroups = computed(() => {
const projectMap = new Map(
@@ -459,8 +463,8 @@ const recentProjectGroups = computed(() => {
})
// ===== Worker runtime context (used by workspace surfaces) =====
const appKey = computed(() => route.meta.workspaceKey || '')
const runtimeCases = computed(() => workerRuntime.tasksBySpecialist[appKey.value] || [])
const workspaceKey = computed(() => route.meta.workspaceKey || '')
const runtimeCases = computed(() => taskRuntime.tasksBySpecialist[workspaceKey.value] || [])
const currentCases = computed(() => {
if (runtimeCases.value.length) {
return runtimeCases.value.map(item => ({
@@ -473,11 +477,11 @@ const currentCases = computed(() => {
status: item.status || '待处理',
}))
}
return appKey.value ? getAppCases(appKey.value) : []
return workspaceKey.value ? getWorkspaceCases(workspaceKey.value) : []
})
const selectedCaseId = computed(() => {
if (!appKey.value) return ''
return workbench.selectedCases[appKey.value] || currentCases.value[0]?.id || getDefaultCaseId(appKey.value)
if (!workspaceKey.value) return ''
return workbench.selectedCases[workspaceKey.value] || currentCases.value[0]?.id || getDefaultWorkspaceCaseId(workspaceKey.value)
})
onMounted(() => {
@@ -503,11 +507,11 @@ function handleShortcut(event) {
}
watch(
appKey,
workspaceKey,
async (key) => {
if (!key) return
try {
await workerRuntime.loadTasks(key)
await taskRuntime.loadTasks(key)
} catch (error) {
console.warn('load worker tasks for shell failed', error)
}
@@ -518,10 +522,10 @@ watch(
watch(
() => currentCases.value.map(item => item.id).join(','),
(ids) => {
if (!appKey.value || !ids) return
const selected = workbench.selectedCases[appKey.value]
if (!workspaceKey.value || !ids) return
const selected = workbench.selectedCases[workspaceKey.value]
if (!selected || !currentCases.value.some(item => item.id === selected)) {
workbench.setSelectedCase(appKey.value, currentCases.value[0]?.id || getDefaultCaseId(appKey.value))
workbench.setSelectedCase(workspaceKey.value, currentCases.value[0]?.id || getDefaultWorkspaceCaseId(workspaceKey.value))
}
},
{ immediate: true }
@@ -532,7 +536,7 @@ watch(
async (id) => {
if (!id || !/^\d+$/.test(String(id))) return
try {
await workerRuntime.loadTaskDetail(id)
await taskRuntime.loadTaskDetail(id)
} catch (error) {
console.warn('load worker task detail for shell failed', error)
}
@@ -547,7 +551,7 @@ watch(
// (右侧工作台要用),以及把路由喂给 store —— 折算成哪个胶囊由 store 一处决定。
const specialist = specialistCatalog.getByPath(path)
if (specialist?.key && !workbench.selectedCases[specialist.key]) {
workbench.setSelectedCase(specialist.key, getDefaultCaseId(specialist.key))
workbench.setSelectedCase(specialist.key, getDefaultWorkspaceCaseId(specialist.key))
}
workbench.ensureTab({ pageRoute: path })
},
@@ -581,7 +585,7 @@ function openSection(section) {
// createTaskForFirstMessage),标题就是那句话,专员用 + 里先选的那个。
// 点一次建一条的话,连点几下就会在「最近任务」里堆出一列没聊过天的空任务。
function startNewTask() {
workerRuntime.startNewTask()
taskRuntime.startNewTask()
router.push('/home')
// 从别的页面过来时组件会重新挂载,画布本来就是干净的;已经停在 /home
// 时靠这个事件把当前对话清掉。两种情况都发一次,调用方不用自己判断。
@@ -590,7 +594,7 @@ function startNewTask() {
async function loadMyTasks() {
try {
await workerRuntime.loadMyTasks()
await taskRuntime.loadMyTasks()
} catch (error) {
console.warn('load my tasks for shell failed', error)
}
@@ -607,8 +611,8 @@ async function loadMyTasks() {
// 注意:对话内容目前没有按任务落库,所以重开一条旧任务时恢复的是「在哪做」
// 和「用哪个技能做」,不是完整消息历史。
async function openMyTask(task) {
workerRuntime.openTask(task.id, task.specialist_key)
const currentSkill = skillCatalog.getByKey(workerRuntime.skillKeyOfTask(task.id))
taskRuntime.openTask(task.id, task.specialist_key)
const currentSkill = skillCatalog.getByKey(taskRuntime.skillKeyOfTask(task.id))
const target = currentSkill?.objectEntryRoute || '/home'
if (route.path !== target) await router.push(target)
}
@@ -657,7 +661,7 @@ async function onTaskCommand(command, task) {
if (command === 'pin') {
try {
await workerRuntime.togglePinned(task.id)
await taskRuntime.togglePinned(task.id)
} catch (error) {
console.warn('toggle task pinned failed', error)
ElMessage.error(task.pinned ? '取消置顶失败,请稍后重试' : '置顶失败,请稍后重试')
@@ -683,7 +687,7 @@ async function onTaskCommand(command, task) {
return // 点了取消
}
try {
await workerRuntime.deleteMyTask(task.id)
await taskRuntime.deleteMyTask(task.id)
ElMessage.success('已删除')
} catch (error) {
console.warn('delete task failed', error)
@@ -727,7 +731,7 @@ async function confirmJoinProject() {
joiningProject.value = true
try {
const previousProjectId = task.project_id || 0
const updatedTask = await workerRuntime.assignTaskToProject(task.id, projectId)
const updatedTask = await taskRuntime.assignTaskToProject(task.id, projectId)
projectStore.syncProjectTask(updatedTask, previousProjectId)
const project = projectStore.projects.find((item) => String(item.id) === String(projectId))
ElMessage.success(`已加入项目${project?.name ? `「${project.name}」` : ''}`)
@@ -766,7 +770,7 @@ async function commitRename(task) {
renamingId.value = null
if (!title || title === task.title) return
try {
await workerRuntime.renameTask(task.id, title)
await taskRuntime.renameTask(task.id, title)
} catch (error) {
console.warn('rename task failed', error)
ElMessage.error('重命名失败,请稍后重试')
@@ -801,10 +805,10 @@ function formatDueAt(value) {
return `${month}-${day} ${hour}:${minute}`
}
function resolveAppChatContext(path) {
if (path.startsWith('/courses')) {
function resolveSurfaceChatContext(path) {
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.courses)) {
return {
scopeKey: 'app-training',
scopeKey: 'surface-training',
title: '培训协作',
subtitle: '边看课程,边让 AI 帮你总结、提炼和辅导。',
placeholder: '例如:帮我总结这门课程的重点,并给出陪练建议',
@@ -815,9 +819,9 @@ function resolveAppChatContext(path) {
],
}
}
if (path.startsWith('/products')) {
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.products)) {
return {
scopeKey: 'app-product',
scopeKey: 'surface-product',
title: '产品协作',
subtitle: '随时提问产品知识、收费结构和销售表达。',
placeholder: '例如:帮我解释这个产品的收费结构和销售话术',
@@ -828,9 +832,9 @@ function resolveAppChatContext(path) {
],
}
}
if (path.startsWith('/company-train')) {
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.company)) {
return {
scopeKey: 'app-company-train',
scopeKey: 'surface-company-train',
title: '介绍协作',
subtitle: '结合公司介绍内容,随时生成摘要、讲解稿和问答。',
placeholder: '例如:把这段公司介绍整理成新人培训讲解稿',
@@ -841,9 +845,9 @@ function resolveAppChatContext(path) {
],
}
}
if (path.startsWith('/exam')) {
if (path.startsWith(INTERNAL_EXAM_XAPP_ROOT)) {
return {
scopeKey: 'app-exam',
scopeKey: 'surface-exam',
title: '考试协作',
subtitle: '边看考试页,边让 AI 帮你解释规则、复盘和出练习建议。',
placeholder: '例如:根据当前考试情况帮我做一版复盘建议',
@@ -1,254 +0,0 @@
<template>
<div class="path-coach-panel">
<!-- 头部 -->
<div class="panel-header">
<div class="panel-title-wrap">
<span class="panel-title">AI PathCoach</span>
<span class="panel-points" :class="{ low: aiPoints <= 0 }">剩余点数 {{ aiPoints }}</span>
</div>
<el-button text @click="aiChat.toggle()">
<el-icon><Close /></el-icon>
</el-button>
</div>
<!-- 消息列表 -->
<div class="message-list" ref="msgListRef">
<div
v-for="(msg, idx) in aiChat.messages"
:key="idx"
:class="['message', msg.role === 'user' ? 'user-msg' : 'assistant-msg']"
>
<div :class="msg.role === 'user' ? 'ai-bubble-user' : 'ai-bubble-assistant'">
{{ msg.content }}
</div>
</div>
<!-- 初始快捷动作 -->
<div v-if="aiChat.messages.length === 0" class="quick-actions">
<div class="quick-actions-title">快捷操作</div>
<div class="quick-actions-grid">
<el-button
v-for="action in quickActions"
:key="action.id"
class="quick-action-btn"
:loading="quickLoading === action.id"
@click="sendQuickAction(action.id)"
>
<el-icon v-if="actionIcon(action.id) === 'ChatLineRound'"><ChatLineRound /></el-icon>
<el-icon v-else-if="actionIcon(action.id) === 'Money'"><Money /></el-icon>
<el-icon v-else-if="actionIcon(action.id) === 'Compare'"><Refresh /></el-icon>
<span>{{ action.label }}</span>
</el-button>
</div>
</div>
</div>
<!-- 输入区 -->
<div class="input-area">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
placeholder="向 AI PathCoach 提问..."
:disabled="aiChat.isStreaming"
@keydown.ctrl.enter="sendMessage"
/>
<el-button
type="primary"
:loading="aiChat.isStreaming"
@click="sendMessage"
style="margin-top: 8px; width: 100%"
>
{{ aiChat.isStreaming ? '思考中...' : '发送 (Ctrl+Enter)' }}
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { useAiChatStore } from '@/store/aiChat'
import { quickActions as fetchQuickActions, quickAction, streamChat, getAiMe } from '@/api/ai'
const aiChat = useAiChatStore()
const inputText = ref('')
const msgListRef = ref(null)
const quickActions = ref([])
const quickLoading = ref('')
const aiPoints = ref(0)
function actionIcon(id) {
return { scenario: 'ChatLineRound', commission: 'Money', compare: 'Compare' }[id] || ''
}
function scrollToBottom() {
nextTick(() => {
if (msgListRef.value) {
msgListRef.value.scrollTop = msgListRef.value.scrollHeight
}
})
}
async function loadQuickActions() {
try {
const res = await fetchQuickActions()
quickActions.value = res.data?.actions || []
} catch (e) {
quickActions.value = []
}
}
async function loadPoints() {
try {
const res = await getAiMe()
aiPoints.value = res.data?.ai_points ?? 0
} catch {
/* ignore */
}
}
async function sendMessage() {
const text = inputText.value.trim()
if (!text || aiChat.isStreaming) return
inputText.value = ''
const history = aiChat.messages.map((m) => ({ role: m.role, content: m.content }))
aiChat.addMessage({ role: 'user', content: text })
aiChat.addMessage({ role: 'assistant', content: '' })
const lastIdx = aiChat.messages.length - 1
aiChat.setStreaming(true)
scrollToBottom()
try {
await streamChat(text, aiChat.context || {}, history, (chunk) => {
aiChat.messages[lastIdx].content += chunk
scrollToBottom()
})
} catch (e) {
aiChat.messages[lastIdx].content = e?.message || '请求失败,请稍后重试'
} finally {
aiChat.setStreaming(false)
loadPoints()
scrollToBottom()
}
}
async function sendQuickAction(actionId) {
const label = quickActions.value.find((a) => a.id === actionId)?.label || actionId
aiChat.addMessage({ role: 'user', content: `[${label}]` })
aiChat.addMessage({ role: 'assistant', content: '' })
const lastIdx = aiChat.messages.length - 1
quickLoading.value = actionId
scrollToBottom()
try {
const res = await quickAction(actionId, aiChat.context || {})
aiChat.messages[lastIdx].content = res.data?.result || '(无返回)'
} catch (e) {
aiChat.messages[lastIdx].content = e?.message || '请求失败'
} finally {
quickLoading.value = ''
loadPoints()
scrollToBottom()
}
}
onMounted(() => {
loadQuickActions()
loadPoints()
})
</script>
<style scoped>
.path-coach-panel {
width: var(--ai-panel-width, 360px);
height: 100vh;
background: var(--surface-bg);
border-left: 1px solid var(--surface-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-header {
height: var(--top-rail-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
border-bottom: 1px solid var(--surface-border);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.panel-title-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--surface-text);
}
.panel-points {
font-size: 12px;
color: #67c23a;
}
.panel-points.low {
color: #f56c6c;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
.message {
display: flex;
flex-direction: column;
}
.user-msg {
align-items: flex-end;
}
.assistant-msg {
align-items: flex-start;
}
.quick-actions {
margin-top: 24px;
}
.quick-actions-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
text-align: center;
}
.quick-actions-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.quick-action-btn {
width: 100%;
justify-content: flex-start;
gap: 8px;
}
.input-area {
padding: 12px 16px;
border-top: 1px solid var(--surface-border);
}
</style>
+59 -48
View File
@@ -1,11 +1,16 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ExamTabsView from '@/views/exam/ExamTabsView.vue'
import {
INTERNAL_EXAM_MODULE_ROUTES,
INTERNAL_EXAM_XAPP_ROOT,
INTERNAL_TRAINING_MODULE_ROUTES,
INTERNAL_TRAINING_XAPP_ROOT,
} from '@/xapps/config/xappRoutes'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
name: 'LoginPage',
component: () => import('@/views/LoginPage.vue'),
},
{
path: '/',
@@ -13,57 +18,63 @@ const routes = [
redirect: '/home',
children: [
// 通用助手 is the front door; /home is its canonical URL.
{ path: 'home', name: 'Home', component: () => import('@/views/workbench/SmartAssistantPage.vue') },
{ path: 'home', name: 'SmartAssistantPage', component: () => import('@/views/workbench/SmartAssistantPage.vue') },
// 项目:装着任务的容器。列表是落地页,详情页带「任务」tab。
{ path: 'projects', name: 'Projects', component: () => import('@/views/workbench/ProjectsPage.vue') },
{ path: 'projects/:id', name: 'ProjectDetail', component: () => import('@/views/workbench/ProjectDetailPage.vue') },
{ path: 'long-apps', name: 'LongApps', component: () => import('@/views/workbench/LongAppPage.vue') },
{ path: 'projects', name: 'ProjectsPage', component: () => import('@/views/workbench/ProjectsPage.vue') },
{ path: 'projects/:id', name: 'ProjectDetailPage', component: () => import('@/views/workbench/ProjectDetailPage.vue') },
{ path: 'xapps', name: 'XAppDirectoryPage', component: () => import('@/xapps/views/XAppDirectoryPage.vue') },
{ path: 'overview', redirect: '/projects' },
{ path: 'my/settings', name: 'MySettings', component: () => import('@/views/me/MySettingsPage.vue') },
{ path: 'admin', name: 'AdminOverview', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } },
{ path: 'studio', name: 'Studio', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } },
{ path: 'console', name: 'Console', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } },
{ path: 'catalog/specialists', name: 'CapabilityCatalogSpecialists', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') },
{ path: 'catalog/skills', name: 'CapabilityCatalogSkills', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') },
{ path: 'catalog/apps', name: 'CapabilityCatalogApps', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') },
{ path: 'catalog/connectors', name: 'CapabilityCatalogConnectors', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') },
{ path: 'catalog/:catalogType/:key', name: 'CapabilityCatalogDetail', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') },
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
{ path: 'docs-center', name: 'DocumentCenter', component: () => import('@/views/workbench/DocumentCenterPage.vue') },
{ path: 'knowledge-management', name: 'KnowledgeManagement', component: () => import('@/views/workbench/KnowledgeManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'market', name: 'Market', component: () => import('@/views/workbench/MarketPage.vue'), meta: { requiresAdmin: true } },
{ path: 'company-train', name: 'CompanyTrain', component: () => import('@/views/companyTrain/CompanyTrain.vue') },
{ path: 'products', name: 'ProductList', component: () => import('@/views/product/ProductList.vue') },
{ path: 'products/:id', name: 'ProductDetail', component: () => import('@/views/product/ProductDetail.vue') },
{ path: 'courses', name: 'CourseList', component: () => import('@/views/salesTrain/CourseList.vue') },
{ path: 'courses/:id', name: 'CourseDetail', component: () => import('@/views/salesTrain/CourseDetail.vue') },
// 考试评估 — 父子路由:父路由带 tabs,子路由各页面
{ path: 'my/settings', name: 'MySettingsPage', component: () => import('@/views/me/MySettingsPage.vue') },
{ path: 'admin', name: 'AdminOverviewPage', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } },
{ path: 'studio', name: 'StudioPage', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } },
{ path: 'console', name: 'ConsolePage', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } },
{ path: 'catalog/specialists', name: 'ObjectCatalogSpecialistsPage', component: () => import('@/specialists/views/SpecialistCatalogPage.vue') },
{ path: 'catalog/skills', name: 'ObjectCatalogSkillsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') },
{ path: 'catalog/xapps', name: 'ObjectCatalogXAppsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') },
{ path: 'catalog/connectors', name: 'ObjectCatalogConnectorsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') },
{ path: 'catalog/specialists/:key', name: 'SpecialistDetailPage', component: () => import('@/specialists/views/SpecialistDetailRoutePage.vue') },
{ path: 'catalog/:catalogType/:key', name: 'ObjectCatalogDetailPage', component: () => import('@/views/workbench/ObjectCatalogDetailRoutePage.vue') },
{ path: 'knowledge-hub', name: 'KnowledgeHubPage', component: () => import('@/views/workbench/KnowledgeHubPage.vue') },
{ path: 'docs-center', name: 'DocumentCenterPage', component: () => import('@/views/workbench/DocumentCenterPage.vue') },
{ path: 'knowledge-management', name: 'KnowledgeManagementPage', component: () => import('@/views/workbench/KnowledgeManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'market', name: 'MarketPage', component: () => import('@/views/workbench/MarketPage.vue'), meta: { requiresAdmin: true } },
{
path: 'exam',
component: ExamTabsView,
redirect: '/exam/self-test',
path: INTERNAL_TRAINING_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/TrainingXAppShell.vue'),
redirect: INTERNAL_TRAINING_MODULE_ROUTES.courses,
children: [
{ path: 'self-test', name: 'ExamSelfTest', component: () => import('@/views/exam/ExamSelfTest.vue') },
{ path: 'formal', name: 'ExamFormal', component: () => import('@/views/exam/ExamFormal.vue') },
{ path: 'my-records', name: 'ExamMyRecord', component: () => import('@/views/exam/ExamMyRecord.vue') },
{ path: 'my-mistakes', name: 'MyMistakes', component: () => import('@/views/exam/MyMistakes.vue') },
{ path: 'my-certificates', name: 'MyCertificates', component: () => import('@/views/exam/MyCertificates.vue') },
{ path: 'my-position', name: 'MyPosition', component: () => import('@/views/exam/MyPosition.vue') },
{ path: 'leaderboard', name: 'Leaderboard', component: () => import('@/views/exam/Leaderboard.vue') },
{ path: 'courses', name: 'TrainingCourseListPage', component: () => import('@/views/salesTrain/CourseListPage.vue') },
{ path: 'courses/:id', name: 'TrainingCourseDetailPage', component: () => import('@/views/salesTrain/CourseDetailPage.vue') },
{ path: 'products', name: 'TrainingProductListPage', component: () => import('@/views/product/ProductListPage.vue') },
{ path: 'products/:id', name: 'TrainingProductDetailPage', component: () => import('@/views/product/ProductDetailPage.vue') },
{ path: 'company-intro', name: 'TrainingCompanyPage', component: () => import('@/views/companyTrain/CompanyTrainPage.vue') },
],
},
// 学习档案独立(不在 tabs 内)
{ path: 'exam/my-profile', name: 'MyProfile', component: () => import('@/views/exam/MyProfile.vue') },
{ path: 'knowledge/materials', name: 'MaterialManage', component: () => import('@/views/knowledge/MaterialManage.vue') },
{ path: 'knowledge/audit', name: 'MaterialAudit', component: () => import('@/views/knowledge/MaterialAudit.vue') },
{ path: 'knowledge/questions', name: 'ExamQuestionBank', component: () => import('@/views/knowledge/ExamQuestionBank.vue') },
{ path: 'knowledge/exam-records', name: 'ExamRecordManage', component: () => import('@/views/knowledge/ExamRecordManage.vue') },
{ path: 'system/positions', name: 'PositionManage', component: () => import('@/views/system/PositionManage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/departments', name: 'DepartmentManage', component: () => import('@/views/system/DepartmentManage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/users', name: 'UserManage', component: () => import('@/views/system/UserManage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/company-config', name: 'CompanyConfig', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-usage', name: 'AiUsage', component: () => import('@/views/system/AiUsage.vue'), meta: { requiresAdmin: true } },
{
path: INTERNAL_EXAM_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/ExamXAppShell.vue'),
redirect: INTERNAL_EXAM_MODULE_ROUTES.selfTest,
children: [
{ path: 'self-test', name: 'ExamSelfTestPage', component: () => import('@/views/exam/ExamSelfTestPage.vue') },
{ path: 'formal', name: 'ExamFormalPage', component: () => import('@/views/exam/ExamFormalPage.vue') },
{ path: 'my-records', name: 'ExamMyRecordPage', component: () => import('@/views/exam/ExamMyRecordPage.vue') },
{ path: 'my-mistakes', name: 'MyMistakesPage', component: () => import('@/views/exam/MyMistakesPage.vue') },
{ path: 'my-certificates', name: 'MyCertificatesPage', component: () => import('@/views/exam/MyCertificatesPage.vue') },
{ path: 'my-position', name: 'MyPositionPage', component: () => import('@/views/exam/MyPositionPage.vue') },
{ path: 'leaderboard', name: 'LeaderboardPage', component: () => import('@/views/exam/LeaderboardPage.vue') },
{ path: 'my-profile', name: 'MyProfilePage', component: () => import('@/views/exam/MyProfilePage.vue') },
],
},
{ path: 'knowledge/materials', name: 'KnowledgeMaterialsPage', component: () => import('@/views/knowledge/KnowledgeMaterialsPage.vue') },
{ path: 'knowledge/audit', name: 'KnowledgeMaterialAuditPage', component: () => import('@/views/knowledge/KnowledgeMaterialAuditPage.vue') },
{ path: 'knowledge/questions', name: 'KnowledgeQuestionBankPage', component: () => import('@/views/knowledge/KnowledgeQuestionBankPage.vue') },
{ path: 'knowledge/exam-records', name: 'KnowledgeExamRecordsPage', component: () => import('@/views/knowledge/KnowledgeExamRecordsPage.vue') },
{ path: 'system/positions', name: 'PositionManagementPage', component: () => import('@/views/system/PositionManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/departments', name: 'DepartmentManagementPage', component: () => import('@/views/system/DepartmentManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/users', name: 'UserManagementPage', component: () => import('@/views/system/UserManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/company-config', name: 'CompanyConfigPage', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/config', name: 'SystemConfigPage', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-usage', name: 'AiUsagePage', component: () => import('@/views/system/AiUsagePage.vue'), meta: { requiresAdmin: true } },
],
},
]
@@ -1,4 +1,4 @@
import http from './http'
import http from '@/api/http'
export function listSkills(params) {
return http.get('/skills', { params })
@@ -13,7 +13,7 @@
// 选项值逐字抄自各技能页原有的 markup,没有增删改(那几页的 markup 已经没了,
// 这里就是唯一的一份)。
// 「真发给后端的」和「只是摆着的」在每条上标了 note,别让后来人以为都能用。
import { assistantModes } from './workbench'
import { assistantModes } from '@/skills/core/builtin'
// 7 种语言,文档翻译的源/目标共用一份(顺序照它的 markup:源列表 zh 打头,目标列表 en 打头)
const LANGS = [
@@ -32,7 +32,7 @@ const MODE_SUM = { quick: '快速模式 · 直出回答', expert: '深思模式
export const skillSettings = {
// 缺省技能。它只有一个设置:模式。原来散在门厅上的两颗胶囊就是它。
// mode 是真发出去的:SmartAssistantPage 的 send() → sendAssistantChatMessage({ message, mode })。
// mode 是真发出去的:SmartAssistantPage 的 send() → sendChatMessage({ message, mode })。
'smart-assistant': {
ico: '通',
settings: [
@@ -0,0 +1,172 @@
export const SKILL_WORKBENCH_ROUTE = '/home'
export function createInteractionCard({
greeting,
tagline,
openingPrompt,
relationshipToUser = '',
tone = '专业、直接、可执行',
starterPrompts = [],
} = {}) {
return {
greeting,
tagline,
openingPrompt,
relationshipToUser,
tone,
starterPrompts,
}
}
export function createSkillWorkflowStep({
title,
detail,
expectedOutput = '',
} = {}) {
return {
title,
detail,
expectedOutput,
}
}
export function createSkillArtifact({
title,
detail,
artifactType = 'text',
fileFormat = 'txt',
status = '默认产物',
} = {}) {
return {
title,
detail,
artifactType,
fileFormat,
status,
}
}
export function createSkillManifest({
id = '',
code = '',
key,
label,
caption,
objectEntryRoute = SKILL_WORKBENCH_ROUTE,
color = '#409eff',
source = 'eai',
kind = 'skill',
icon = 'skill',
description = '',
skillTags = [],
workflowSchema = [],
artifactSchema = [],
interactionCard = {},
officeMeta = null,
} = {}) {
return {
id,
code,
source,
key,
label,
kind,
icon,
caption,
description,
objectEntryRoute,
color,
skillTags,
workflowSchema,
artifactSchema,
interactionCard: createInteractionCard(interactionCard),
officeMeta,
}
}
export function createOfficeMeta({
category = 'office',
artifactType = 'document',
fileFormat = 'md',
defaultPrompt = '',
resultTitle = '',
summary = '',
highlights = [],
taskPlanTitle = '',
planSteps = [],
} = {}) {
return {
category,
artifactType,
fileFormat,
defaultPrompt,
resultTitle,
summary,
highlights,
taskPlanTitle,
planSteps,
}
}
export function createOfficeSkillManifest({
id,
code,
key,
label,
category = 'office',
artifactType = 'document',
fileFormat = 'md',
description = '',
defaultPrompt = '',
resultTitle = '',
summary = '',
highlights = [],
taskPlanTitle = '',
planSteps = [],
color = '#409eff',
} = {}) {
return createSkillManifest({
id,
code,
key,
label,
caption: description,
description,
color,
skillTags: Array.isArray(highlights) ? highlights : [],
workflowSchema: Array.isArray(planSteps)
? planSteps.map((step) =>
createSkillWorkflowStep({
title: step,
detail: description,
expectedOutput: resultTitle,
}))
: [],
artifactSchema: [
createSkillArtifact({
title: resultTitle || label,
detail: summary || description,
artifactType,
fileFormat,
}),
],
interactionCard: {
greeting: summary || `我是${label},可以帮你先整理第一版结果。`,
tagline: description || label,
openingPrompt: defaultPrompt || `告诉我你希望${label}先处理什么。`,
relationshipToUser: '你的技能助手',
starterPrompts: defaultPrompt ? [defaultPrompt] : [],
},
officeMeta: createOfficeMeta({
category,
artifactType,
fileFormat,
defaultPrompt,
resultTitle,
summary,
highlights,
taskPlanTitle: taskPlanTitle || `${label}流程`,
planSteps,
}),
})
}
@@ -0,0 +1,74 @@
import { withObjectCodes } from '@/utils/objectCode'
import { createRegistry } from '@/skills/core/registry'
import smartAssistantManifest from '@/skills/packages/smart-assistant/manifest'
import documentTranslateManifest from '@/skills/packages/document-translate/manifest'
import copyProofreadingManifest from '@/skills/packages/copy-proofreading/manifest'
import audioTranscribeManifest from '@/skills/packages/audio-transcribe/manifest'
import batchExtractManifest from '@/skills/packages/batch-extract/manifest'
import contractReviewManifest from '@/skills/packages/contract-review/manifest'
import reportGenerationManifest from '@/skills/packages/report-generation/manifest'
import pptGenerationManifest from '@/skills/packages/ppt-generation/manifest'
import mindMapManifest from '@/skills/packages/mind-map/manifest'
import longformWritingManifest from '@/skills/packages/longform-writing/manifest'
import textToolkitManifest from '@/skills/packages/text-toolkit/manifest'
import ocrUnderstandingManifest from '@/skills/packages/ocr-understanding/manifest'
import meetingMinutesManifest from '@/skills/packages/meeting-minutes/manifest'
import projectPlanningManifest from '@/skills/packages/project-planning/manifest'
import emailDraftingManifest from '@/skills/packages/email-drafting/manifest'
import tableCleanupManifest from '@/skills/packages/table-cleanup/manifest'
import proposalSummaryManifest from '@/skills/packages/proposal-summary/manifest'
import progressReportManifest from '@/skills/packages/progress-report/manifest'
import contractBriefManifest from '@/skills/packages/contract-brief/manifest'
import interviewSummaryManifest from '@/skills/packages/interview-summary/manifest'
import policyRewriteManifest from '@/skills/packages/policy-rewrite/manifest'
import surveySummaryManifest from '@/skills/packages/survey-summary/manifest'
// Register office skill execution builders (side-effect import).
// This centralizes all package→execution imports into one place.
import '@/skills/packages/officeExecutions'
export const builtinSkillManifests = [
smartAssistantManifest,
documentTranslateManifest,
copyProofreadingManifest,
audioTranscribeManifest,
batchExtractManifest,
contractReviewManifest,
reportGenerationManifest,
pptGenerationManifest,
mindMapManifest,
longformWritingManifest,
textToolkitManifest,
ocrUnderstandingManifest,
meetingMinutesManifest,
projectPlanningManifest,
emailDraftingManifest,
tableCleanupManifest,
proposalSummaryManifest,
progressReportManifest,
contractBriefManifest,
interviewSummaryManifest,
policyRewriteManifest,
surveySummaryManifest,
]
const builtinSkillRegistry = createRegistry(builtinSkillManifests)
// 通用助手的模式。快速模式直出回答,深思模式开启 thinking 多步规划。
export const assistantModes = [
{ key: 'quick', label: '快速模式', icon: '⚡', desc: '直出回答,适合简单问题和快速回复。', placeholder: '输入你的问题...' },
{ key: 'expert', label: '深思模式', icon: '🧠', desc: '开启思考过程,适合复杂任务和深度分析。', placeholder: '输入需要深度分析的问题...' },
]
export function getBuiltinSkillRegistry() {
return builtinSkillRegistry
}
export const staticSkillCatalog = withObjectCodes(
builtinSkillRegistry.manifests(),
{ kind: 'skill' },
)
export function getStaticSkillByKey(key) {
return staticSkillCatalog.find((item) => item.key === key) || null
}
@@ -0,0 +1,13 @@
export function createRegistry(items = []) {
const manifests = items.filter((item) => item && item.key)
const manifestMap = Object.fromEntries(manifests.map((item) => [item.key, item]))
return {
manifests() {
return manifests.map((item) => ({ ...item }))
},
byKey(key) {
return manifestMap[String(key || '').trim()] || null
},
}
}
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'audio-transcribe',
label: '语音转写',
caption: '转写 / 分段 / 重点整理',
color: '#5263c3',
skillTags: ['逐字转写', '说话人分段', '重点提炼', '纪要整理'],
workflowSchema: [
createSkillWorkflowStep({
title: '确认音频范围与语言',
detail: '先判断音频语言、时长、说话场景,以及是否需要区分说话人。',
expectedOutput: '转写要求',
}),
createSkillWorkflowStep({
title: '生成逐字转写文本',
detail: '尽量保留原始信息,避免在转写阶段过早删减内容。',
expectedOutput: '原始转写稿',
}),
createSkillWorkflowStep({
title: '整理段落与重点',
detail: '按主题、说话人或时间顺序把文本收成可阅读结构。',
expectedOutput: '结构化转写稿',
}),
createSkillWorkflowStep({
title: '提炼可交付纪要',
detail: '根据需要输出会议纪要、行动项或重点摘要。',
expectedOutput: '纪要或行动项',
}),
],
artifactSchema: [
createSkillArtifact({
title: '逐字转写稿',
detail: '保留原始信息的基础文本。',
artifactType: 'transcript',
fileFormat: 'txt',
}),
createSkillArtifact({
title: '结构化纪要',
detail: '按主题或说话人整理后的可读版本。',
artifactType: 'document',
fileFormat: 'md',
}),
createSkillArtifact({
title: '行动项清单',
detail: '适合从会议或访谈中提炼后续动作。',
artifactType: 'checklist',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先把语音内容转成可靠文本,再继续整理重点、纪要和后续动作。',
tagline: '把音频从“听过”变成“可查、可引用、可交付”的文本资产。',
openingPrompt: '把音频内容发给我,或者告诉我语言、场景和你最终想拿到什么结果。',
relationshipToUser: '你的语音整理技能',
starterPrompts: [
'把这段会议录音转成文字,按议题分段,再列出待办事项',
'先转写这段采访,再帮我提炼可引用的核心观点',
'把这段语音整理成适合发群里的会议纪要',
],
},
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'batch-extract',
label: '批量提取',
caption: '字段设计 / 批量抽取 / 异常复核',
color: '#39b07a',
skillTags: ['字段抽取', '批量处理', '结构化输出', '异常回看'],
workflowSchema: [
createSkillWorkflowStep({
title: '定义抽取字段',
detail: '先把需要的字段、输出格式和异常处理规则说清楚,避免提取结果不可用。',
expectedOutput: '字段方案',
}),
createSkillWorkflowStep({
title: '批量识别源材料',
detail: '对一批文本、合同、简历或文章做统一识别和结构化抽取。',
expectedOutput: '批量抽取结果',
}),
createSkillWorkflowStep({
title: '标记异常与缺失项',
detail: '对低置信度、字段缺失和格式不一致的内容单独挑出来。',
expectedOutput: '异常项清单',
}),
createSkillWorkflowStep({
title: '整理为交付表',
detail: '收口成表格、JSON 或可继续流转的数据结果。',
expectedOutput: '结构化交付物',
}),
],
artifactSchema: [
createSkillArtifact({
title: '结构化结果表',
detail: '批量抽取后的主交付表。',
artifactType: 'table',
fileFormat: 'xlsx',
}),
createSkillArtifact({
title: '异常项清单',
detail: '需要人工复核或补齐的信息会单独列出。',
artifactType: 'checklist',
fileFormat: 'md',
}),
createSkillArtifact({
title: '字段说明',
detail: '说明本次字段口径和空值规则,便于复用。',
artifactType: 'note',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先把字段方案定义清楚,再批量抽取,并把异常项单独挑出来。',
tagline: '不是只把内容抓出来,而是直接整理成后续系统能用的结构化结果。',
openingPrompt: '告诉我想抽哪些字段、输出成什么格式、哪些情况需要标记为异常。',
relationshipToUser: '你的结构化提取技能',
starterPrompts: [
'从这批简历里提取姓名、岗位、年限和意向城市,异常项单独列出',
'把这些合同里的付款条款统一抽出来,并标出缺失付款节点的合同',
'帮我批量提取文章标题、作者、发布时间和摘要,输出成表格',
],
},
})
@@ -0,0 +1,130 @@
import {
buildContractRedlineItems,
extractPoints,
parseStructuredFields,
pickTopic,
slugify,
splitFocusKeywords,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
topic: ['合同名称', '主题', 'topic'],
role: ['立场', '我方立场', 'role'],
focus: ['重点条款', '重点关注', 'focus'],
counterpart: ['对方', '合作方', 'counterpart'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '合同要点')
const role = fields.role || '我方视角'
const focus = fields.focus || '付款、违约、终止、争议解决'
const counterpart = fields.counterpart || '对方主体待确认'
const points = extractPoints(bodyText || inputText, [
'合同目的与合作范围',
'付款与结算安排',
'违约责任与赔偿约定',
'终止条件与争议处理',
])
const focusKeywords = splitFocusKeywords(focus)
const redlineItems = buildContractRedlineItems(points, focusKeywords)
const summaryContent = [
`# ${topic}`,
'',
'## 审查视角',
`- 立场:${role}`,
`- 重点条款:${focus}`,
`- 对方主体:${counterpart}`,
'',
'## 核心条款',
...points.slice(0, 4).map((item) => `- ${item}`),
'',
'## 阅读建议',
'建议先快速确认付款、违约、终止三类关键条款,再进入逐条审阅。',
].join('\n').trim()
const riskContent = [
'# 风险提示',
'',
`- 风险 1:${points[2] || '违约责任约定可能偏重单方义务'}`,
`- 风险 2:${points[3] || '终止或争议处理条款仍需继续确认'}`,
'- 建议:重点核对责任边界、赔偿上限和解除条件',
].join('\n').trim()
const confirmContent = [
'# 待确认事项',
'',
'- [ ] 付款节点与验收口径是否一致',
'- [ ] 违约责任是否存在明显失衡',
'- [ ] 终止条件与争议解决地是否可接受',
].join('\n').trim()
const redlineContent = [
'# 红线建议',
'',
'| 条款 | 主要风险 | 建议红线 |',
'| --- | --- | --- |',
...redlineItems.map((item) => `| ${item.clause} | ${item.risk} | ${item.suggestion} |`),
].join('\n').trim()
const clauseReviewContent = [
'# 逐条审查意见',
'',
'| 条款 | 审查结论 | 主要问题 | 建议动作 |',
'| --- | --- | --- | --- |',
...redlineItems.map((item, index) => `| ${item.clause} | ${index === 0 ? '需重点关注' : index === 1 ? '建议修改' : '建议复核'} | ${item.risk} | ${item.suggestion} |`),
].join('\n').trim()
const slug = slugify(topic, 'contract-brief')
return {
title: '合同要点摘要',
summary: `已按“${role} / ${focus}”提炼出合同核心条款、风险和待确认事项。`,
highlights: ['适合采购、合作、服务类合同', '会识别立场、重点条款和对方信息', '新增逐条审查意见和红线建议'],
artifacts: [
{
key: 'summary',
title: '合同摘要',
description: '适合业务和负责人快速预读的核心条款摘要。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: summaryContent,
},
{
key: 'risks',
title: '风险提示',
description: '提炼出的主要风险点和审查建议。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-risks.md`,
content: riskContent,
},
{
key: 'confirm',
title: '待确认事项',
description: '需要继续确认或人工复核的关键事项。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-confirm.md`,
content: confirmContent,
},
{
key: 'redline',
title: '红线建议',
description: '更接近合同审查稿的修改建议版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-redline.md`,
content: redlineContent,
},
{
key: 'clause-review',
title: '逐条审查意见',
description: '更像法务审查单的逐条意见表。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-review.md`,
content: clauseReviewContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.contract-brief',
code: 'EAI-T-212',
key: 'contract-brief',
label: '合同要点提炼',
category: 'document',
artifactType: 'summary',
fileFormat: 'md',
color: '#dc2626',
description: '把合同、协议和条款材料压缩成核心要点、风险提示和待确认事项,方便业务快速预读。',
defaultPrompt: '把这份采购合同提炼成要点摘要和待确认项',
resultTitle: '合同要点摘要',
summary: '已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。',
highlights: ['适合采购、合作、服务类合同', '自动压缩核心条款与风险点', '默认沉淀摘要、风险和待确认项'],
taskPlanTitle: '合同要点提炼流程',
planSteps: ['识别合同结构', '提炼核心条款', '标记风险事项', '输出摘要版本'],
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'contract-review',
label: '合同审查',
caption: '条款识别 / 风险判断 / 审查交付',
color: '#f56c6c',
skillTags: ['风险识别', '待确认项', '红线建议', '审查结果'],
workflowSchema: [
createSkillWorkflowStep({
title: '识别合同背景与重点条款',
detail: '先判断合同类型、审查立场和这次最需要盯住的业务条款。',
expectedOutput: '审查范围',
}),
createSkillWorkflowStep({
title: '抽取关键条款',
detail: '定位付款、责任、违约、终止、保密等核心条款,建立审查底稿。',
expectedOutput: '条款清单',
}),
createSkillWorkflowStep({
title: '判断风险与待确认项',
detail: '区分高风险、可接受偏差和必须回到业务侧确认的问题。',
expectedOutput: '风险与待确认项',
}),
createSkillWorkflowStep({
title: '整理审查交付物',
detail: '输出可直接用于沟通或复核的风险清单、红线建议和审查摘要。',
expectedOutput: '审查结果包',
}),
],
artifactSchema: [
createSkillArtifact({
title: '风险清单',
detail: '按条款列出高风险、一般风险和待确认事项。',
artifactType: 'checklist',
fileFormat: 'xlsx',
}),
createSkillArtifact({
title: '红线建议',
detail: '可直接进入 redline 或谈判沟通的修改建议。',
artifactType: 'document',
fileFormat: 'docx',
}),
createSkillArtifact({
title: '审查摘要',
detail: '适合给负责人快速汇报的审查结论。',
artifactType: 'summary',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先定位高风险条款和待确认项,再整理成可交付的审查结果。',
tagline: '不是只报风险,而是把风险依据、红线建议和后续确认项一起交出来。',
openingPrompt: '把合同发给我,并告诉我这次重点想看哪些条款,或者你站在哪一方立场。',
relationshipToUser: '你的合同审查技能',
starterPrompts: [
'审查这份采购合同的风险点,重点关注付款责任、违约责任和终止条款',
'站在甲方立场帮我看这份合同,给我一版待人工确认的风险清单',
'先抽出关键条款,再整理 redline 建议和业务待确认点',
],
},
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'copy-proofreading',
label: '文案校对',
caption: '问题定位 / 表达优化 / 风格收口',
color: '#e6a23c',
skillTags: ['错别字检查', '语病修正', '语气统一', '风格优化'],
workflowSchema: [
createSkillWorkflowStep({
title: '识别文本目标与语气',
detail: '先判断这段文案是对内沟通、对外邮件、销售话术还是正式说明。',
expectedOutput: '校对口径与风格目标',
}),
createSkillWorkflowStep({
title: '定位问题句与风险表达',
detail: '标出错别字、病句、歧义和不够稳妥的地方,不只给最终改写。',
expectedOutput: '问题清单',
}),
createSkillWorkflowStep({
title: '重写关键句段',
detail: '对重点段落进行润色,兼顾清晰度、礼貌度和行业语感。',
expectedOutput: '优化后的正文',
}),
createSkillWorkflowStep({
title: '收口成可发布版本',
detail: '给出最终可直接使用的版本,并说明是否还需要人工确认。',
expectedOutput: '定稿建议',
}),
],
artifactSchema: [
createSkillArtifact({
title: '校对问题清单',
detail: '列出原文中的高优先级问题,方便快速复核。',
artifactType: 'checklist',
fileFormat: 'md',
}),
createSkillArtifact({
title: '优化后正文',
detail: '可直接复制使用的修订版本。',
artifactType: 'document',
fileFormat: 'docx',
}),
createSkillArtifact({
title: '风格说明',
detail: '解释本次主要调整了哪些语气和表达策略。',
artifactType: 'note',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先找出问题句和语气风险,再给出能直接使用的修订版本。',
tagline: '不是只改错字,而是把整段表达修顺、修稳、修清楚。',
openingPrompt: '把文案贴给我,并告诉我这段内容要发给谁、希望保留什么语气。',
relationshipToUser: '你的文案润色技能',
starterPrompts: [
'帮我校对这封对外邮件,保留原意,但语气要更专业稳妥',
'把这段销售话术里的病句和夸张表述挑出来,再帮我润顺',
'先指出这段产品介绍哪里别扭,再给我一版正式写法',
],
},
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'document-translate',
label: '文档翻译',
caption: '译文生成 / 术语统一 / 多格式导出',
color: '#67c23a',
skillTags: ['术语统一', '语气对齐', '格式保留', '双语摘要'],
workflowSchema: [
createSkillWorkflowStep({
title: '识别语境与翻译要求',
detail: '先确定源语言、目标语言、受众、语气和是否需要保留术语表。',
expectedOutput: '翻译口径与术语要求',
}),
createSkillWorkflowStep({
title: '统一术语与表达风格',
detail: '优先消除专有名词和关键术语的前后不一致,避免译文看起来像拼接。',
expectedOutput: '术语映射与风格基线',
}),
createSkillWorkflowStep({
title: '生成目标语言正文',
detail: '在保留原意的前提下重写成目标语言读者真正能读懂的表达。',
expectedOutput: '首版译文',
}),
createSkillWorkflowStep({
title: '整理为可交付版本',
detail: '按需要补充双语摘要、术语对照或导出格式说明。',
expectedOutput: '可直接提交的译稿',
}),
],
artifactSchema: [
createSkillArtifact({
title: '目标语言译文',
detail: '适合直接复制、发送或继续编辑的主结果。',
artifactType: 'document',
fileFormat: 'docx',
}),
createSkillArtifact({
title: '术语对照表',
detail: '保留关键术语的源文与译法,便于统一口径。',
artifactType: 'table',
fileFormat: 'xlsx',
}),
createSkillArtifact({
title: '双语摘要',
detail: '适合管理层快速了解文档内容和重点。',
artifactType: 'summary',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先识别语境和术语,再生成可交付译文,避免只做逐句直译。',
tagline: '不仅翻译文字,还会先统一术语、语气和交付格式。',
openingPrompt: '把原文贴给我,并说明目标语言、受众和语气要求;如果有术语表,也一起给我。',
relationshipToUser: '你的翻译协作技能',
starterPrompts: [
'把这份中文方案翻译成英文,保留正式商务语气,并列出关键术语对照',
'把这封邮件翻译成日文,语气礼貌但不要太生硬',
'先把这份合同摘要译成英文,再给我一版双语对照',
],
},
})
@@ -0,0 +1,119 @@
import {
buildEmailSubjectVariants,
extractPoints,
parseStructuredFields,
pickTopic,
slugify,
splitSimpleList,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
recipient: ['收件人', '对象', 'recipient', 'to'],
cc: ['抄送', '抄送人', 'cc'],
topic: ['主题', '标题', 'topic', 'subject'],
purpose: ['目的', '意图', 'purpose'],
action: ['希望对方', '需要对方', 'action'],
deadline: ['时间', '截止时间', 'deadline'],
tone: ['语气', '风格', 'tone', 'style'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '邮件主题')
const recipient = fields.recipient || '各位'
const ccList = splitSimpleList(fields.cc)
const purpose = fields.purpose || '同步背景和当前进展'
const action = fields.action || '请协助确认并继续推进'
const deadline = fields.deadline || '请尽快反馈'
const tone = fields.tone || '正式、明确、礼貌'
const points = extractPoints(bodyText || inputText, [
'交代背景和当前情况',
'说明希望对方配合或确认的事项',
'给出时间点和下一步动作',
'保持礼貌但结论清楚',
])
const subjectVariants = buildEmailSubjectVariants(topic, purpose)
const subject = subjectVariants[0]
const emailContent = [
`主题:${subject}`,
`收件人:${recipient}`,
`抄送:${ccList.length ? ccList.join('、') : '无'}`,
'',
`${recipient}好,`,
'',
`${purpose}。${points[0] || '这边同步一下当前事项的背景和进展。'}`,
`${action}。${points[1] || '目前需要大家协助确认相关事项。'}`,
`${deadline}。${points[2] || '如无问题,请按约定时间继续推进。'}`,
'',
`语气建议:${tone}。如有问题,请直接回复沟通。`,
'',
'谢谢。',
].join('\n').trim()
const shortContent = [
`【${topic}】`,
`${points[1] || '请协助确认相关事项。'}`,
`${points[2] || '如无问题请按时推进。'}`,
].join('\n').trim()
const briefContent = [
'# 邮件要点',
'',
`- 主题:${subject}`,
...points.slice(0, 4).map((item) => `- ${item}`),
].join('\n').trim()
const subjectContent = [
'# 主题与抄送建议',
'',
`- 收件人:${recipient}`,
`- 抄送建议:${ccList.length ? ccList.join('、') : '建议抄送直属负责人 / 相关协同方'}`,
'',
'## 可选主题',
...subjectVariants.map((item, index) => `${index + 1}. ${item}`),
].join('\n').trim()
const slug = slugify(topic, 'email-drafting')
return {
title: '邮件草稿',
summary: `已按“${recipient} / ${topic}”生成正式邮件、短消息版和邮件要点摘要。`,
highlights: ['适合通知、催办、确认和汇报邮件', '会识别收件人、抄送、目的、时间和语气', '新增可直接挑选的主题变体'],
artifacts: [
{
key: 'email',
title: '正式邮件',
description: '可直接复制到邮箱发送的完整版本。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}.txt`,
content: emailContent,
},
{
key: 'short-followup',
title: '简短跟进版',
description: '适合发到 IM 或群里的短消息版本。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}-short.txt`,
content: shortContent,
},
{
key: 'brief',
title: '邮件要点',
description: '方便审批或复核邮件口径的要点版。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-brief.md`,
content: briefContent,
},
{
key: 'subject-variants',
title: '主题与抄送建议',
description: '提供多版主题候选和抄送建议。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-subject.md`,
content: subjectContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.email-drafting',
code: 'EAI-T-208',
key: 'email-drafting',
label: '邮件起草',
category: 'communication',
artifactType: 'document',
fileFormat: 'txt',
color: '#0f766e',
description: '把沟通意图、背景和要求整理成可直接发送的正式邮件,并附带简短跟进版本。',
defaultPrompt: '帮我起草一封推进客户回款的正式邮件',
resultTitle: '邮件草稿',
summary: '已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。',
highlights: ['适合汇报、催办、确认和通知', '兼顾正式语气与清晰表达', '默认沉淀长邮件和短消息版本'],
taskPlanTitle: '邮件起草流程',
planSteps: ['识别沟通目的', '整理背景与重点', '生成正式邮件', '补充简短跟进版本'],
})
@@ -0,0 +1,77 @@
import {
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '访谈整理')
const points = extractPoints(inputText, [
'受访者关注的核心问题',
'当前使用中的主要痛点',
'对方案或产品的积极反馈',
'后续建议和动作',
])
const summaryContent = [
`# ${topic}`,
'',
'## 主题摘要',
...points.slice(0, 4).map((item) => `- ${item}`),
'',
'## 访谈结论',
'访谈内容已压缩为主题和观点,适合继续沉淀为需求或汇报材料。',
].join('\n').trim()
const insightContent = [
'# 洞察结论',
'',
`- 洞察 1:${points[0] || '受访者更关注核心目标是否真正落地'}`,
`- 洞察 2:${points[1] || '当前流程中存在明显使用阻力或理解成本'}`,
`- 洞察 3:${points[2] || '已有价值点需要进一步明确放大'}`,
].join('\n').trim()
const actionsContent = [
'# 后续建议',
'',
`- [ ] ${points[3] || '将访谈结论转成下一步行动项'}`,
'- [ ] 对重点洞察做进一步验证',
'- [ ] 同步相关负责人决定是否进入后续迭代',
].join('\n').trim()
const slug = slugify(topic, 'interview-summary')
return {
title: '访谈整理稿',
summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。',
highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'],
artifacts: [
{
key: 'summary',
title: '访谈摘要',
description: '访谈主题和观点的整理版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: summaryContent,
},
{
key: 'insights',
title: '洞察结论',
description: '从访谈中提炼出的关键洞察。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-insights.md`,
content: insightContent,
},
{
key: 'actions',
title: '后续建议',
description: '建议继续推进或验证的动作。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-actions.md`,
content: actionsContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.interview-summary',
code: 'EAI-T-213',
key: 'interview-summary',
label: '访谈整理',
category: 'summary',
artifactType: 'document',
fileFormat: 'md',
color: '#7c3aed',
description: '把访谈记录、用户调研和深度沟通内容整理成主题摘要、洞察结论和后续动作。',
defaultPrompt: '把这份用户访谈记录整理成主题摘要和洞察结论',
resultTitle: '访谈整理稿',
summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。',
highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'],
taskPlanTitle: '访谈整理流程',
planSteps: ['归并访谈内容', '提炼主题观点', '总结关键洞察', '输出整理版本'],
})
@@ -0,0 +1,59 @@
import {
buildMarkdownOutline,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '长文主题')
const sections = [
{ title: '背景与问题', bullets: ['交代问题背景', '说明为什么现在要做这件事'] },
{ title: '目标与原则', bullets: ['写清楚目标边界', '说明采用的基本原则'] },
{ title: '主体分析', bullets: ['从现状、问题、机会三个角度展开', '补充关键证据与案例'] },
{ title: '方案与建议', bullets: ['提出可执行的方案路径', '给出必要的资源与配套要求'] },
{ title: '结论与下一步', bullets: ['收束核心结论', '明确下一步动作'] },
]
const outlineMarkdown = buildMarkdownOutline(`${topic} 长文大纲`, sections)
const draftContent = [
`# ${topic}`,
'',
'## 摘要',
`${topic} 这项工作需要先把背景、目标、方案和执行动作组织清楚,再进一步扩写为完整正文。本稿先给出一版可继续加工的长文骨架。`,
'',
...sections.flatMap((section) => [
`## ${section.title}`,
`${section.title}部分应围绕“${topic}”展开,建议先给出清晰判断,再用材料、案例和拆解步骤支撑结论。`,
...section.bullets.map((bullet) => `- ${bullet}`),
'',
]),
].join('\n').trim()
const slug = slugify(topic, 'longform')
return {
title: '长文草稿',
summary: '已输出一版长文大纲和正文骨架,方便继续细化成正式稿。',
highlights: ['先给大纲再给正文草稿', '适合方案、制度、长文章', '结果可直接下载继续改写'],
artifacts: [
{
key: 'outline',
title: '章节大纲',
description: '先确认结构是否合理。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-outline.md`,
content: outlineMarkdown,
},
{
key: 'draft',
title: '正文草稿',
description: '可继续细化的正文版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-draft.md`,
content: draftContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.longform-writing',
code: 'EAI-T-203',
key: 'longform-writing',
label: '长文创作',
category: 'writing',
artifactType: 'document',
fileFormat: 'docx',
color: '#b45309',
description: '统一承接论文、方案、制度文档、公众号长文等长文任务,先搭大纲,再分段生成正文。',
defaultPrompt: '围绕企业 AI 落地写一篇完整方案',
resultTitle: '长文草稿',
summary: '已按长文写作流程生成大纲与正文骨架,适合继续细化为正式稿。',
highlights: ['支持论文、方案、制度、长文', '先搭结构再出正文', '默认沉淀大纲、摘要和正文稿'],
taskPlanTitle: '长文创作流程',
planSteps: ['明确文体与受众', '规划章节大纲', '生成正文骨架', '整理摘要与交付稿'],
})
@@ -0,0 +1,96 @@
import {
extractPoints,
parseMeetingActions,
parseStructuredFields,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
topic: ['会议主题', '主题', 'title', 'topic'],
date: ['时间', '日期', 'date'],
attendees: ['参会人', '参会人员', 'attendees'],
objective: ['目标', '目的', 'objective'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '会议纪要')
const meetingDate = fields.date || '待补充'
const attendees = fields.attendees || '待补充'
const objective = fields.objective || '对齐进展、结论和行动项'
const points = extractPoints(bodyText || inputText, [
'确认本次会议目标和范围',
'同步当前进展与关键问题',
'明确下一步行动和负责人',
'记录需要继续跟进的依赖项',
])
const actions = parseMeetingActions(bodyText || inputText, points)
const minutesContent = [
`# ${topic}`,
'',
'## 会议摘要',
`${topic} 已整理为正式纪要版本,建议先快速确认结论和行动项,再同步到群里或项目任务中。`,
'',
'## 会议信息',
`- 时间:${meetingDate}`,
`- 参会人:${attendees}`,
`- 会议目标:${objective}`,
'',
'## 关键结论',
...points.slice(0, 4).map((item) => `- ${item}`),
'',
'## 待办事项',
...actions.map((item, index) => `${index + 1}. ${item.owner} - ${item.task}(截止:${item.due})`),
].join('\n').trim()
const actionContent = [
'# 会后行动项',
'',
'| 序号 | 负责人 | 行动项 | 截止时间 | 状态 |',
'| --- | --- | --- | --- | --- |',
...actions.map((item, index) => `| ${index + 1} | ${item.owner} | ${item.task} | ${item.due} | 待开始 |`),
].join('\n').trim()
const syncContent = [
`【${topic}】`,
...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`),
...actions.slice(0, 2).map((item) => `- ${item.owner}:${item.task}(${item.due})`),
'请相关同事按行动项继续推进。',
].join('\n').trim()
const slug = slugify(topic, 'meeting-minutes')
return {
title: '会议纪要草稿',
summary: `已按“${topic}”整理出纪要、行动项和会后同步摘要。`,
highlights: ['包含纪要正文、行动项和同步摘要', '会识别时间、参会人和会议目标', '行动项会拆出负责人和截止时间'],
artifacts: [
{
key: 'minutes',
title: '会议纪要',
description: '适合直接发送或继续细化的正式纪要。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: minutesContent,
},
{
key: 'actions',
title: '行动项清单',
description: '会后跟进和任务分派用的待办清单。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-actions.md`,
content: actionContent,
},
{
key: 'sync',
title: '会后同步摘要',
description: '适合发到群里或邮件里的简短同步版本。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}-sync.txt`,
content: syncContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.meeting-minutes',
code: 'EAI-T-206',
key: 'meeting-minutes',
label: '会议纪要',
category: 'document',
artifactType: 'document',
fileFormat: 'md',
color: '#7c3aed',
description: '把会议记录、聊天记录和零散讨论整理成正式纪要、决议摘要和待办清单。',
defaultPrompt: '把这段项目例会记录整理成正式会议纪要',
resultTitle: '会议纪要草稿',
summary: '已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。',
highlights: ['自动提炼结论与待办', '适合例会、评审会和复盘会', '默认沉淀纪要正文和行动项清单'],
taskPlanTitle: '会议纪要整理流程',
planSteps: ['识别会议主题', '提炼关键结论', '整理行动项', '输出同步版本'],
})
@@ -0,0 +1,48 @@
import {
buildMindMapSvg,
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '任务导图')
const branches = extractPoints(inputText, [
'目标与范围',
'输入材料',
'关键步骤',
'风险与依赖',
]).slice(0, 6)
const outlineMarkdown = [`# ${topic} 思维导图`, '', ...branches.map((branch) => `- ${branch}`)].join('\n')
const svgContent = buildMindMapSvg(topic, branches)
const slug = slugify(topic, 'mind-map')
return {
title: '导图结构草案',
summary: '已把主题压成一版导图结构,并附带可直接下载的 SVG 导图文件。',
highlights: ['可下载导图 SVG', '保留文字版结构', '适合任务拆解与知识梳理'],
artifacts: [
{
key: 'mindmap-svg',
title: '导图图片',
description: '便于直接查看和分享的 SVG 导图。',
kind: 'svg',
fileFormat: 'svg',
mimeType: 'image/svg+xml;charset=utf-8',
downloadName: `${slug}.svg`,
content: svgContent,
},
{
key: 'mindmap-outline',
title: '导图源结构',
description: '可继续编辑和扩展的文字结构版。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: outlineMarkdown,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.mind-map',
code: 'EAI-T-202',
key: 'mind-map',
label: '思维导图',
category: 'structure',
artifactType: 'mindmap',
fileFormat: 'svg',
color: '#0f766e',
description: '把一句话主题、会议纪要或长文本快速展开成结构化导图,适合梳理任务与知识结构。',
defaultPrompt: '把这次项目启动会整理成一版思维导图',
resultTitle: '导图结构草案',
summary: '已把输入内容压缩成层级结构,方便继续扩展节点或导出成导图文件。',
highlights: ['适合任务拆解与知识梳理', '会优先生成主干与分支', '默认保留可继续编辑的导图源结构'],
taskPlanTitle: '思维导图生成流程',
planSteps: ['识别主题主线', '拆分一级分支', '补齐关键节点', '整理导图源结构'],
})
@@ -0,0 +1,15 @@
import { pickTopic } from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '图文理解')
return {
title: '识别结果摘要',
summary: '这张卡片支持直接上传图片做 OCR 识别,识别完成后可复制文本或下载结果。',
highlights: ['浏览器端直接上传识别', '识别后可复制和下载', '适合截图、扫描件和表格照片'],
interactive: {
type: 'ocr-upload',
prompt: topic,
},
artifacts: [],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.ocr-understanding',
code: 'EAI-T-205',
key: 'ocr-understanding',
label: 'OCR / 图文理解',
category: 'vision',
artifactType: 'document',
fileFormat: 'md',
color: '#0ea5e9',
description: '识别图片、截图和扫描件中的文字与结构信息,并提炼为摘要或结构化字段。',
defaultPrompt: '识别这张截图里的文字并整理成结构化摘要',
resultTitle: '识别结果摘要',
summary: '已按图文理解流程准备好识别与整理结果的第一版结构,方便继续抽字段或生成摘要。',
highlights: ['适合截图、扫描件、表格照片', '支持文字识别与结构摘要', '默认沉淀识别结果和字段草案'],
taskPlanTitle: '图文理解流程',
planSteps: ['识别图像内容', '抽取文字与字段', '整理结构摘要', '输出可复用结果'],
})
@@ -0,0 +1,47 @@
/**
* Central registration of office skill execution builders.
*
* Each package's buildExecution is imported here and self-registered
* into the shared officeExecutionRegistry, so that shared files
* never import directly from individual packages.
*/
import { registerOfficeExecution } from '@/skills/shared/officeExecutionRegistry'
// Communication skills
import { buildExecution as buildEmailDraftingExecution } from '@/skills/packages/email-drafting/execution'
import { buildExecution as buildMeetingMinutesExecution } from '@/skills/packages/meeting-minutes/execution'
import { buildExecution as buildProgressReportExecution } from '@/skills/packages/progress-report/execution'
// Structure skills
import { buildExecution as buildMindMapExecution } from '@/skills/packages/mind-map/execution'
import { buildExecution as buildPptExecution } from '@/skills/packages/ppt-generation/execution'
import { buildExecution as buildProjectPlanningExecution } from '@/skills/packages/project-planning/execution'
import { buildExecution as buildTableCleanupExecution } from '@/skills/packages/table-cleanup/execution'
// Writing skills
import { buildExecution as buildContractBriefExecution } from '@/skills/packages/contract-brief/execution'
import { buildExecution as buildInterviewSummaryExecution } from '@/skills/packages/interview-summary/execution'
import { buildExecution as buildLongformExecution } from '@/skills/packages/longform-writing/execution'
import { buildExecution as buildOcrExecution } from '@/skills/packages/ocr-understanding/execution'
import { buildExecution as buildPolicyRewriteExecution } from '@/skills/packages/policy-rewrite/execution'
import { buildExecution as buildProposalSummaryExecution } from '@/skills/packages/proposal-summary/execution'
import { buildExecution as buildSurveySummaryExecution } from '@/skills/packages/survey-summary/execution'
import { buildExecution as buildTextToolkitExecution } from '@/skills/packages/text-toolkit/execution'
// Register all execution builders
registerOfficeExecution('email-drafting', buildEmailDraftingExecution)
registerOfficeExecution('meeting-minutes', buildMeetingMinutesExecution)
registerOfficeExecution('progress-report', buildProgressReportExecution)
registerOfficeExecution('mind-map', buildMindMapExecution)
registerOfficeExecution('ppt-generation', buildPptExecution)
registerOfficeExecution('project-planning', buildProjectPlanningExecution)
registerOfficeExecution('table-cleanup', buildTableCleanupExecution)
registerOfficeExecution('contract-brief', buildContractBriefExecution)
registerOfficeExecution('interview-summary', buildInterviewSummaryExecution)
registerOfficeExecution('longform-writing', buildLongformExecution)
registerOfficeExecution('ocr-understanding', buildOcrExecution)
registerOfficeExecution('policy-rewrite', buildPolicyRewriteExecution)
registerOfficeExecution('proposal-summary', buildProposalSummaryExecution)
registerOfficeExecution('survey-summary', buildSurveySummaryExecution)
registerOfficeExecution('text-toolkit', buildTextToolkitExecution)
@@ -0,0 +1,82 @@
import {
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '制度改写')
const points = extractPoints(inputText, [
'明确制度适用范围',
'统一执行口径和条件',
'补齐例外和审批规则',
'强调执行责任和留痕要求',
])
const rewriteContent = [
`# ${topic}`,
'',
'## 制度正文',
`${topic} 已重写为更清楚、统一的正式版本,适合继续评审或发布。`,
'',
'### 适用范围',
`- ${points[0] || '明确制度适用对象与范围'}`,
'### 执行要求',
`- ${points[1] || '统一执行口径和基本条件'}`,
`- ${points[2] || '补充例外处理与审批规则'}`,
'### 管理要求',
`- ${points[3] || '强调执行责任和记录留痕'}`,
].join('\n').trim()
const revisionContent = [
'# 修订说明',
'',
'- 已统一制度结构和表达口径',
'- 已补清含糊表达,增强执行可读性',
'- 建议结合实际流程再做一次法务或管理复核',
].join('\n').trim()
const tipsContent = [
'# 执行提示',
'',
'- 发布前建议确认适用范围与责任人',
'- 对例外情形建议补充审批路径',
'- 对留痕要求建议同步模板或系统入口',
].join('\n').trim()
const slug = slugify(topic, 'policy-rewrite')
return {
title: '制度改写稿',
summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。',
highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'],
artifacts: [
{
key: 'rewrite',
title: '改写正文',
description: '适合继续评审或发布的正式版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: rewriteContent,
},
{
key: 'revision',
title: '修订说明',
description: '说明本次主要改写和统一了哪些内容。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-revision.md`,
content: revisionContent,
},
{
key: 'tips',
title: '执行提示',
description: '制度落地前需要注意的执行建议。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-tips.md`,
content: tipsContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.policy-rewrite',
code: 'EAI-T-214',
key: 'policy-rewrite',
label: '制度改写',
category: 'writing',
artifactType: 'document',
fileFormat: 'md',
color: '#6d28d9',
description: '把制度、规范和说明文本重写成更清楚、更统一的正式版本,并补出修订说明。',
defaultPrompt: '把这份报销制度改写成更正式清晰的版本',
resultTitle: '制度改写稿',
summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。',
highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'],
taskPlanTitle: '制度改写流程',
planSteps: ['识别制度目标', '统一结构口径', '重写正式正文', '补充修订说明'],
})
@@ -0,0 +1,96 @@
import {
buildMarkdownOutline,
extractPoints,
parsePositiveInt,
parseStructuredFields,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
topic: ['主题', '标题', 'topic', 'title'],
audience: ['受众', '对象', 'audience'],
goal: ['目标', '用途', 'goal'],
pages: ['页数', '页面数', 'slides', 'pages'],
style: ['风格', '语气', 'style', 'tone'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '汇报主题')
const audience = fields.audience || '管理层 / 项目相关方'
const goal = fields.goal || '帮助受众快速理解核心结论并推动下一步'
const pageCount = Math.max(6, Math.min(parsePositiveInt(fields.pages, 8), 12))
const style = fields.style || '专业、简洁、结论先行'
const points = extractPoints(bodyText || inputText, [
'交代背景和目标',
'整理当前问题与机会',
'给出解决路径与方案',
'明确执行节奏与下一步',
])
const slideSections = [
{ title: '封面', bullets: [topic, `受众:${audience}`, `风格:${style}`] },
{ title: '背景与目标', bullets: points.slice(0, 2) },
{ title: '现状与问题', bullets: points.slice(1, 4) },
{ title: '方案路径', bullets: points.slice(2, 5) },
{ title: '执行计划', bullets: [goal, '阶段 1:启动与对齐', '阶段 2:执行与校正'] },
{ title: '风险与下一步', bullets: ['风险项与依赖', '需要支持的事项', '下一步行动清单'] },
]
while (slideSections.length < pageCount) {
slideSections.splice(slideSections.length - 1, 0, {
title: `补充页 ${slideSections.length - 3}`,
bullets: points.slice(0, 3).length ? points.slice(0, 3) : ['补充论据', '数据支撑', '案例说明'],
})
}
const outlineMarkdown = buildMarkdownOutline(`${topic} PPT 大纲`, slideSections)
const notesMarkdown = slideSections
.map((section, index) => [
`## 第 ${index + 1} 页:${section.title}`,
`- 建议讲法:先说明“${section.title}”的核心判断,再展开 2-3 个重点。`,
...section.bullets.map((bullet) => `- 讲稿提示:${bullet}`),
'',
].join('\n'))
.join('\n')
.trim()
const slug = slugify(topic, 'presentation')
return {
title: '演示文稿骨架',
summary: `已按“${topic} / ${audience} / ${pageCount} 页”生成一版 PPT 结构,同时附上大纲和讲稿备注。`,
highlights: ['支持直接下载 .pptx', '能识别主题、受众、页数等信息', '同时保留可读的大纲与讲稿备注'],
artifacts: [
{
key: 'presentation',
title: '演示文稿',
description: '浏览器端即时生成的 PPT 文件。',
kind: 'pptx',
fileFormat: 'pptx',
downloadName: `${slug}.pptx`,
data: {
topic,
slides: slideSections,
},
},
{
key: 'outline',
title: '演示大纲',
description: '适合先确认页面结构和内容分配。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-outline.md`,
content: outlineMarkdown,
},
{
key: 'notes',
title: '讲稿备注',
description: '逐页的讲解提示和过渡说明。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-notes.md`,
content: notesMarkdown,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.ppt-generation',
code: 'EAI-T-201',
key: 'ppt-generation',
label: 'PPT 生成',
category: 'document',
artifactType: 'presentation',
fileFormat: 'pptx',
color: '#4f46e5',
description: '根据主题、受众和材料,先整理演示逻辑,再生成可继续细化的演示文稿骨架。',
defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲',
resultTitle: '演示文稿骨架',
summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。',
highlights: ['适合周报、汇报、方案、复盘', '会先拆页面逻辑,再组织每页要点', '默认沉淀演示大纲与讲稿备注'],
taskPlanTitle: 'PPT 生成流程',
planSteps: ['确认汇报目标', '规划页面结构', '提炼每页要点', '整理讲稿备注'],
})
@@ -0,0 +1,82 @@
import {
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '进展汇报')
const points = extractPoints(inputText, [
'本期完成的关键工作',
'当前风险和阻塞项',
'需要协同支持的事项',
'下一步计划',
])
const reportContent = [
`# ${topic}`,
'',
'## 本期进展',
`- ${points[0] || '已完成本期关键工作整理'}`,
`- ${points[1] || '已同步当前风险和问题'}`,
'',
'## 风险与问题',
`- ${points[1] || '当前仍有待继续确认的风险项'}`,
`- ${points[2] || '存在需要跨团队协同支持的事项'}`,
'',
'## 下步计划',
`- ${points[3] || '按既定节奏继续推进下一步工作'}`,
'- 根据反馈更新排期和优先级',
].join('\n').trim()
const summaryContent = [
`【${topic}】`,
`1. ${points[0] || '已完成本期关键工作整理'}`,
`2. ${points[1] || '已同步当前风险和问题'}`,
`3. ${points[3] || '下一步按计划继续推进'}`,
].join('\n').trim()
const checklistContent = [
'# 跟进清单',
'',
`- [ ] ${points[1] || '继续跟进风险项'}`,
`- [ ] ${points[2] || '推动跨团队协同事项'}`,
`- [ ] ${points[3] || '执行下一步计划'}`,
].join('\n').trim()
const slug = slugify(topic, 'progress-report')
return {
title: '进展汇报草稿',
summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。',
highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'],
artifacts: [
{
key: 'report',
title: '正式汇报',
description: '适合直接发送或继续细化的正式版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: reportContent,
},
{
key: 'summary',
title: '简短摘要',
description: '适合发群或站会快速同步的简洁版。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}-summary.txt`,
content: summaryContent,
},
{
key: 'checklist',
title: '跟进清单',
description: '需要继续推进的风险与事项列表。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-checklist.md`,
content: checklistContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.progress-report',
code: 'EAI-T-211',
key: 'progress-report',
label: '周报 / 日报',
category: 'report',
artifactType: 'document',
fileFormat: 'md',
color: '#16a34a',
description: '把工作记录、聊天记录和事项进展整理成日报、周报或阶段同步,补齐风险和下步计划。',
defaultPrompt: '根据这些工作记录整理一版项目周报',
resultTitle: '进展汇报草稿',
summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。',
highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'],
taskPlanTitle: '进展汇报流程',
planSteps: ['归并本期工作', '提炼关键进展', '整理风险事项', '输出汇报版本'],
})
@@ -0,0 +1,115 @@
import {
buildProjectStageRows,
extractPoints,
parseStructuredFields,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
topic: ['项目', '项目名称', '主题', 'topic'],
deadline: ['时间要求', '截止时间', 'deadline'],
owner: ['负责人', 'owner'],
goal: ['目标', 'goal'],
start: ['开始时间', '启动时间', 'start'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '项目规划')
const deadline = fields.deadline || '待确认'
const owner = fields.owner || '待指定'
const goal = fields.goal || '先明确目标、阶段、依赖和协同方式'
const start = fields.start || '尽快启动'
const points = extractPoints(bodyText || inputText, [
'明确目标、范围和成功标准',
'梳理关键里程碑与交付节点',
'安排执行分工与协同节奏',
'识别风险与外部依赖',
])
const stageRows = buildProjectStageRows(points, owner, deadline)
const planContent = [
`# ${topic}`,
'',
'## 项目目标',
`围绕“${topic}”整理一版可执行的项目规划,先确保目标、阶段和依赖都清楚。`,
`- 负责人:${owner}`,
`- 启动时间:${start}`,
`- 时间要求:${deadline}`,
`- 总体目标:${goal}`,
'',
'## 阶段安排',
'### 阶段 1:启动与对齐',
`- ${points[0] || '明确目标和边界'}`,
'### 阶段 2:执行与推进',
`- ${points[1] || '推进关键里程碑和核心交付'}`,
'### 阶段 3:验收与收口',
`- ${points[2] || '完成验收、复盘和沉淀'}`,
].join('\n').trim()
const milestoneContent = [
'# 里程碑清单',
'',
...points.slice(0, 5).map((item, index) => `${index + 1}. M${index + 1}:${item}`),
].join('\n').trim()
const riskContent = [
'# 风险与依赖',
'',
`- 风险 1:${points[3] || '关键依赖尚未完全明确'}`,
'- 风险 2:跨团队协同节奏可能影响排期',
'- 建议:将关键依赖提前前置到周会或启动会确认',
].join('\n').trim()
const ganttContent = [
'# 阶段排期表',
'',
'| 阶段 | 时间窗 | 负责人 | 目标 | 交付物 |',
'| --- | --- | --- | --- | --- |',
...stageRows.map((item) => `| ${item.stage} | ${item.window} | ${item.owner} | ${item.goal} | ${item.output} |`),
].join('\n').trim()
const slug = slugify(topic, 'project-planning')
return {
title: '项目规划草案',
summary: `已按“${topic} / ${deadline}”整理出阶段计划、里程碑和风险清单。`,
highlights: ['阶段计划清晰', '会识别负责人、时间要求和项目目标', '新增可直接评审的阶段排期表'],
artifacts: [
{
key: 'project-plan',
title: '项目计划',
description: '适合直接评审和继续细化的主计划。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: planContent,
},
{
key: 'milestones',
title: '里程碑清单',
description: '关键节点和交付节奏的简洁版列表。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-milestones.md`,
content: milestoneContent,
},
{
key: 'risks',
title: '风险清单',
description: '项目推进前需要确认的风险与依赖。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-risks.md`,
content: riskContent,
},
{
key: 'schedule',
title: '阶段排期表',
description: '更接近甘特视角的阶段、时间窗和交付物表。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-schedule.md`,
content: ganttContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.project-planning',
code: 'EAI-T-207',
key: 'project-planning',
label: '项目规划',
category: 'planning',
artifactType: 'plan',
fileFormat: 'md',
color: '#2563eb',
description: '根据目标、约束和现状整理项目阶段、里程碑、执行分工和风险清单。',
defaultPrompt: '为新产品上线项目整理一版执行计划和里程碑',
resultTitle: '项目规划草案',
summary: '已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。',
highlights: ['适合启动、排期和推进对齐', '默认沉淀阶段计划与风险清单', '结果适合继续流转到任务治理'],
taskPlanTitle: '项目规划流程',
planSteps: ['明确目标边界', '拆解阶段路径', '整理里程碑', '补齐风险与依赖'],
})
@@ -0,0 +1,78 @@
import {
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '方案摘要')
const points = extractPoints(inputText, [
'明确方案目标和解决的问题',
'提炼核心价值与收益',
'压缩关键实施路径',
'明确需要决策的事项',
])
const summaryContent = [
`# ${topic}`,
'',
'## 核心结论',
`${topic} 已压缩为适合汇报和快速预读的摘要版本,重点保留目标、价值点和实施建议。`,
'',
'## 关键亮点',
...points.slice(0, 4).map((item) => `- ${item}`),
'',
'## 实施建议',
'- 建议先确认资源与排期',
'- 建议围绕高优先级场景先落一版 MVP',
'- 建议同步关键负责人做进一步评审',
].join('\n').trim()
const highlightsContent = [
'# 亮点清单',
'',
...points.slice(0, 5).map((item, index) => `${index + 1}. ${item}`),
].join('\n').trim()
const briefingContent = [
`【${topic} 摘要】`,
...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`),
'建议按上述结论进入下一步决策或评审。',
].join('\n').trim()
const slug = slugify(topic, 'proposal-summary')
return {
title: '方案摘要稿',
summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。',
highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'],
artifacts: [
{
key: 'summary',
title: '方案摘要',
description: '适合直接汇报或继续细化的主摘要。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: summaryContent,
},
{
key: 'highlights',
title: '亮点清单',
description: '压缩提炼后的价值点列表。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-highlights.md`,
content: highlightsContent,
},
{
key: 'briefing',
title: '汇报短版',
description: '适合发群或口头汇报前快速同步的短摘要。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}-briefing.txt`,
content: briefingContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.proposal-summary',
code: 'EAI-T-210',
key: 'proposal-summary',
label: '方案摘要',
category: 'summary',
artifactType: 'document',
fileFormat: 'md',
color: '#9333ea',
description: '把长方案、材料包和售前文档压缩成高密度摘要,保留核心结论、价值点和实施建议。',
defaultPrompt: '把这份解决方案压成一版管理层可读摘要',
resultTitle: '方案摘要稿',
summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。',
highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'],
taskPlanTitle: '方案摘要流程',
planSteps: ['识别方案目标', '提炼核心亮点', '压缩实施路径', '输出摘要版本'],
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'report-generation',
label: '报告生成',
caption: '材料归并 / 结构规划 / 正式输出',
color: '#9c27b0',
skillTags: ['日报周报', '复盘总结', '章节规划', '管理摘要'],
workflowSchema: [
createSkillWorkflowStep({
title: '收拢材料与报告目标',
detail: '先看清楚素材来源、受众、汇报周期和需要强调的重点。',
expectedOutput: '报告口径',
}),
createSkillWorkflowStep({
title: '规划章节结构',
detail: '把零散信息归到清楚的章节框架里,避免报告堆砌流水账。',
expectedOutput: '报告大纲',
}),
createSkillWorkflowStep({
title: '生成正文与关键结论',
detail: '补齐背景、进展、问题、风险和建议,形成可读性强的主体内容。',
expectedOutput: '报告正文',
}),
createSkillWorkflowStep({
title: '压缩成可汇报版本',
detail: '提炼管理摘要、行动项和下一步建议,便于直接提交或汇报。',
expectedOutput: '正式报告',
}),
],
artifactSchema: [
createSkillArtifact({
title: '报告大纲',
detail: '先快速确认结构是否合理。',
artifactType: 'outline',
fileFormat: 'md',
}),
createSkillArtifact({
title: '正式报告',
detail: '包含正文、关键结论和建议的主交付物。',
artifactType: 'report',
fileFormat: 'docx',
}),
createSkillArtifact({
title: '管理摘要',
detail: '适合负责人快速阅读的一页总结。',
artifactType: 'summary',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '先把材料归并成结构,再输出可直接提交的正式报告。',
tagline: '不只是把记录拼起来,而是把过程信息收成完整、清楚、可汇报的结果。',
openingPrompt: '把材料给我,并告诉我报告类型、受众、周期和你最想突出哪些重点。',
relationshipToUser: '你的报告整理技能',
starterPrompts: [
'把今天的工作记录整理成日报,顺手补出关键风险和明日计划',
'根据这些项目节点生成周报,给负责人一版能直接看的摘要',
'把这次交付过程整理成复盘说明,并总结经验和后续建议',
],
},
})
@@ -0,0 +1,66 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
key: 'smart-assistant',
label: '通用助手',
caption: '对话 / 任务拆解 / 文案生成',
color: '#409eff',
skillTags: ['任务澄清', '方案拆解', '对象分派', '交付起步'],
workflowSchema: [
createSkillWorkflowStep({
title: '澄清目标与约束',
detail: '先判断任务目标、时间要求、输入材料和成功标准,避免一上来就误做。',
expectedOutput: '问题边界与执行口径',
}),
createSkillWorkflowStep({
title: '拆解执行路径',
detail: '把事情拆成可推进的步骤,识别是否需要挂载技能、专家或知识库。',
expectedOutput: '下一步清单与对象建议',
}),
createSkillWorkflowStep({
title: '生成首轮结果',
detail: '先给出可继续迭代的首版内容,而不是只停留在建议层。',
expectedOutput: '首版答复或草案',
}),
createSkillWorkflowStep({
title: '收口为可交付结果',
detail: '把临时讨论整理成清单、文案、结构化说明或后续动作。',
expectedOutput: '可继续推进的交付物',
}),
],
artifactSchema: [
createSkillArtifact({
title: '任务拆解清单',
detail: '适合需要先理顺思路、确定下一步的事项。',
artifactType: 'plan',
fileFormat: 'md',
}),
createSkillArtifact({
title: '首版草案',
detail: '正文、说明、话术或分析草案。',
artifactType: 'document',
fileFormat: 'md',
}),
createSkillArtifact({
title: '后续动作建议',
detail: '建议继续挂载的对象、信息缺口与跟进路径。',
artifactType: 'checklist',
fileFormat: 'md',
}),
],
interactionCard: {
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
relationshipToUser: '你的通用协作搭档',
starterPrompts: [
'帮我拆解这个任务,并列出下一步',
'把这段产品介绍整理成正式文案',
'先帮我判断这个问题该找哪个专员或技能',
],
},
})
@@ -0,0 +1,77 @@
import {
extractPoints,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const topic = pickTopic(inputText, '问卷汇总')
const points = extractPoints(inputText, [
'参与者的主要反馈方向',
'高频问题或不满意点',
'值得保留的积极反馈',
'后续改进建议',
])
const reportContent = [
`# ${topic}`,
'',
'## 汇总结论',
...points.slice(0, 4).map((item) => `- ${item}`),
'',
'## 汇总说明',
'已把表单和问卷结果压缩成便于阅读和决策的汇总结论。',
].join('\n').trim()
const findingsContent = [
'# 重点发现',
'',
`- 发现 1:${points[0] || '参与者主要关注体验和执行效果'}`,
`- 发现 2:${points[1] || '部分问题集中在流程或内容安排上'}`,
`- 发现 3:${points[2] || '已有若干积极反馈值得继续保留'}`,
].join('\n').trim()
const actionsContent = [
'# 改进建议',
'',
`- [ ] ${points[3] || '根据汇总结果制定下一步改进动作'}`,
'- [ ] 对高频问题安排专项优化',
'- [ ] 向负责人同步汇总结论和优先级建议',
].join('\n').trim()
const slug = slugify(topic, 'survey-summary')
return {
title: '问卷汇总稿',
summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。',
highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'],
artifacts: [
{
key: 'report',
title: '汇总报告',
description: '适合直接汇报或继续细化的主报告。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: reportContent,
},
{
key: 'findings',
title: '重点发现',
description: '从表单结果中提炼出的关键发现。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-findings.md`,
content: findingsContent,
},
{
key: 'actions',
title: '改进建议',
description: '建议后续继续推进的改进行动。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-actions.md`,
content: actionsContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.survey-summary',
code: 'EAI-T-215',
key: 'survey-summary',
label: '表单问卷汇总',
category: 'report',
artifactType: 'report',
fileFormat: 'md',
color: '#2563eb',
description: '把问卷、报名表和收集表单结果整理成汇总报告、重点发现和后续动作建议。',
defaultPrompt: '把这批培训反馈问卷整理成汇总结论和改进建议',
resultTitle: '问卷汇总稿',
summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。',
highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'],
taskPlanTitle: '问卷汇总流程',
planSteps: ['归并表单结果', '提炼重点发现', '整理问题与建议', '输出汇总版本'],
})
@@ -0,0 +1,98 @@
import {
buildCsvText,
extractPoints,
parseStructuredFields,
parseTableColumns,
parseTableRows,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
export function buildExecution(inputText = '') {
const { fields, bodyText } = parseStructuredFields(inputText, {
topic: ['主题', '标题', 'topic', 'title'],
columns: ['列', '字段', '表头', 'columns'],
})
const topic = fields.topic || pickTopic(bodyText || inputText, '表格整理')
const points = extractPoints(bodyText || inputText, [
'字段 1:主对象',
'字段 2:当前状态',
'字段 3:下一步动作',
'字段 4:备注信息',
])
const columns = parseTableColumns(fields.columns, points)
const rows = parseTableRows(bodyText || inputText, columns, points)
const markdownRows = rows
.map((row, index) => `| ${index + 1} | ${columns.map((_, columnIndex) => row[columnIndex] || '').join(' | ')} |`)
.join('\n')
const csvContent = buildCsvText(['序号', ...columns], rows.map((row, index) => [String(index + 1), ...columns.map((_, columnIndex) => row[columnIndex] || '')]))
const tableContent = [
`# ${topic}`,
'',
`| 序号 | ${columns.join(' | ')} |`,
`| ${['---', ...columns.map(() => '---')].join(' | ')} |`,
markdownRows || '| 1 | 示例项 | 已整理 | 待确认 |',
].join('\n').trim()
const fieldsContent = [
'# 字段说明',
'',
'- `序号`:用于标记记录顺序',
...columns.map((item) => `- \`${item}\`:本次整理后的结构化字段`),
].join('\n').trim()
const notesContent = [
'# 清洗备注',
'',
'- 已对原始内容做字段归并',
'- 已输出 CSV 主文件,便于直接导入 Excel',
'- 建议下一步补充负责人、日期或金额等关键字段',
].join('\n').trim()
const slug = slugify(topic, 'table-cleanup')
return {
title: '表格整理结果',
summary: '已把输入内容整理成结构化表格,并补了 CSV 主文件、字段说明和清洗备注。',
highlights: ['适合名单、台账、记录整理', '支持直接下载 CSV 导入 Excel', '便于继续导入系统或二次清洗'],
artifacts: [
{
key: 'csv',
title: 'CSV 主表',
description: '适合直接导入 Excel 或系统的主文件。',
kind: 'text',
fileFormat: 'csv',
mimeType: 'text/csv;charset=utf-8',
downloadName: `${slug}.csv`,
content: csvContent,
},
{
key: 'table',
title: '表格预览',
description: '适合先快速预览整理结果的 Markdown 版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}.md`,
content: tableContent,
},
{
key: 'fields',
title: '字段说明',
description: '记录各字段的含义和使用方式。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-fields.md`,
content: fieldsContent,
},
{
key: 'notes',
title: '清洗备注',
description: '说明本次整理和清洗做了哪些处理。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-notes.md`,
content: notesContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.table-cleanup',
code: 'EAI-T-209',
key: 'table-cleanup',
label: '表格整理',
category: 'data',
artifactType: 'table',
fileFormat: 'md',
color: '#475569',
description: '把零散字段、半结构化列表和杂乱数据整理成清晰表格,并补出字段说明与清洗备注。',
defaultPrompt: '把这批客户跟进记录整理成结构化表格',
resultTitle: '表格整理结果',
summary: '已把输入内容整理成结构化表格,并补了字段说明和清洗备注。',
highlights: ['适合名单、台账、记录整理', '默认沉淀表格正文和字段说明', '便于继续导入 Excel 或系统'],
taskPlanTitle: '表格整理流程',
planSteps: ['识别字段结构', '清洗原始数据', '整理表格内容', '补充字段说明'],
})
@@ -0,0 +1,62 @@
import {
meaningfulLines,
pickTopic,
slugify,
} from '@/skills/shared/officeArtifacts'
function stripMarkdown(text) {
return meaningfulLines(String(text || '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/[*_>#-]+/g, ' '))
.join('\n')
}
export function buildExecution(inputText = '') {
const cleaned = stripMarkdown(inputText)
const chars = cleaned.length
const lines = meaningfulLines(cleaned).length
const statsContent = [
'# 文本统计',
'',
`- 字符数:${chars}`,
`- 行数:${lines}`,
`- 原始长度:${String(inputText || '').trim().length}`,
'',
'## 处理说明',
'- 已移除常见 Markdown 标记',
'- 已压缩多余空行',
'- 已保留正文可读性',
].join('\n')
const topic = pickTopic(inputText, 'cleaned-text')
const slug = slugify(topic, 'text-toolkit')
return {
title: '清洗后文本',
summary: '已完成 Markdown 去噪、格式整理和文本统计,可直接下载或复制结果。',
highlights: ['去 Markdown 噪音', '输出文本统计', '适合高频复制到 Word / IM 场景'],
artifacts: [
{
key: 'cleaned',
title: '清洗后文本',
description: '适合直接粘贴到文档或聊天工具。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}.txt`,
content: cleaned,
},
{
key: 'stats',
title: '文本统计',
description: '记录本次清洗和统计结果。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-stats.md`,
content: statsContent,
},
],
}
}
@@ -0,0 +1,19 @@
import { createOfficeSkillManifest } from '@/skills/contracts/manifest'
export default createOfficeSkillManifest({
id: 'eai.text-toolkit',
code: 'EAI-T-204',
key: 'text-toolkit',
label: '文本处理',
category: 'text',
artifactType: 'document',
fileFormat: 'md',
color: '#334155',
description: '用于去 Markdown 符号、格式整理、文本统计与批量清洗,让复制出来的内容更适合继续使用。',
defaultPrompt: '把这段 AI 回答清洗成适合粘贴到 Word 的纯文本',
resultTitle: '清洗后文本',
summary: '已按文本处理工具箱模式整理好内容,方便继续复制、统计或二次编辑。',
highlights: ['去 Markdown 噪音', '统一段落和标点', '适合高频复制粘贴场景'],
taskPlanTitle: '文本处理流程',
planSteps: ['识别清洗目标', '去除噪音符号', '统一格式与标点', '输出可复用文本'],
})
@@ -1,8 +1,11 @@
import workbuddyExcelSkill from '@/skills/eai/workbuddy-excel-skill'
import workbuddyExcelSkill from '@/skills/packages/workbuddy-excel-skill'
import OfficeSkillResultCard from '@/skills/shared/OfficeSkillResultCard.vue'
import { createOfficeSkillRegistryDefinitions } from '@/skills/shared/officeSkillCatalog'
import { builtinSkillManifests } from '@/skills/core/builtin'
import { createOfficeSkillDefinition } from '@/skills/shared/officeSkill'
const officeSkillRegistry = createOfficeSkillRegistryDefinitions(OfficeSkillResultCard)
const officeSkillRegistry = builtinSkillManifests
.filter((item) => item?.officeMeta)
.map((item) => createOfficeSkillDefinition(item, OfficeSkillResultCard))
export const eaiSkillRegistry = [workbuddyExcelSkill, ...officeSkillRegistry]
@@ -105,7 +105,7 @@
import { computed, ref } from 'vue'
import { downloadOfficeArtifact } from './officeArtifacts'
import { executeOfficeSkillOcr } from '@/api/officeSkill'
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
import { useTaskRuntimeStore } from '@/store/taskRuntime'
import { useWorkbenchStore } from '@/store/workbench'
const props = defineProps({
@@ -119,7 +119,7 @@ const fileInputRef = ref(null)
const ocrRunning = ref(false)
const ocrText = ref('')
const ocrError = ref('')
const workerRuntime = useWorkerRuntimeStore()
const taskRuntime = useTaskRuntimeStore()
const workbenchStore = useWorkbenchStore()
const requestText = computed(() => props.result?.request?.plainText || '')
@@ -159,12 +159,12 @@ async function handleFileChange(event) {
try {
const formData = new FormData()
formData.append('file', file)
formData.append('task_id', String(workerRuntime.currentTaskId || ''))
formData.append('task_id', String(taskRuntime.currentTaskId || ''))
formData.append('prompt', props.result?.interactive?.prompt || '')
const res = await executeOfficeSkillOcr(formData)
ocrText.value = String(res?.data?.text || '').trim()
if (workerRuntime.currentTaskId) {
await workerRuntime.loadTaskDetail(workerRuntime.currentTaskId)
if (taskRuntime.currentTaskId) {
await taskRuntime.loadTaskDetail(taskRuntime.currentTaskId)
}
} catch (error) {
ocrError.value = error?.message || '图片识别失败,请重试。'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
/**
* Registry for office skill execution builders.
*
* Shared files import from this module only.
* Package executors register themselves via `registerOfficeExecution`.
*/
const officeExecutionRegistry = Object.freeze(Object.create(null))
/**
* Register an execution builder for an office skill.
* Call this from package-level index files so that each package self-registers.
*
* @param {string} skillKey - The unique key of the skill (e.g. 'ppt-generation')
* @param {function} builder - A function that takes inputText and returns { title, summary, highlights, artifacts, ... }
*/
export function registerOfficeExecution(skillKey, builder) {
if (typeof skillKey === 'string' && skillKey.trim()) {
officeExecutionRegistry[skillKey.trim()] = builder
}
}
/**
* Retrieve the execution builder for a given skill key.
*
* @param {string|undefined} skillKey
* @returns {function|null}
*/
export function getOfficeExecutionBuilder(skillKey) {
const key = String(skillKey || '').trim()
return key ? (officeExecutionRegistry[key] || null) : null
}
/**
* Build an office skill execution result by looking up the builder
* for the skill defined in the manifest.
*
* @param {Object} definition - The skill manifest/definition object (must have .key)
* @param {string} inputText - The input text to process
* @returns {Object} Execution result
*/
export function buildOfficeSkillExecution(definition, inputText = '') {
const builder = getOfficeExecutionBuilder(definition?.key)
if (!builder) {
return {
title: definition?.label || '技能结果',
summary: definition?.description || '',
highlights: [],
artifacts: [],
}
}
return builder(inputText)
}
@@ -0,0 +1,93 @@
import { SKILL_SOURCES } from './types'
import {
buildComposerBlocks,
hasSendableContent,
serializeBlocksToText,
} from './runtime'
import { buildOfficeSkillExecution } from './officeExecutionRegistry'
import { executeOfficeSkill as executeOfficeSkillRequest } from '@/api/officeSkill'
export function createOfficeSkillDefinition(manifest, resultCard) {
return {
id: String(manifest?.id || `eai.${manifest?.key || 'office-skill'}`),
code: String(manifest?.code || '').trim(),
source: manifest?.source || SKILL_SOURCES.EAI,
key: String(manifest?.key || '').trim(),
label: String(manifest?.label || '').trim(),
kind: manifest?.kind || 'skill',
icon: manifest?.icon || 'skill',
description: manifest?.description || manifest?.caption || '',
officeMeta: manifest?.officeMeta || {},
executor: (context = {}) => executeOfficeSkill({ ...context, definition: manifest }),
resultCard,
}
}
export function buildOfficeSkillPayload(definition, inputText = '') {
const blocks = buildComposerBlocks({
resource: {
key: definition?.key,
label: definition?.label,
uri: `skill://${definition?.key}`,
description: definition?.description,
skillId: definition?.key,
icon: definition?.icon || 'skill',
},
text: inputText,
})
return {
blocks,
plainText: serializeBlocksToText(blocks),
sendable: hasSendableContent(blocks),
artifactType: definition?.officeMeta?.artifactType || 'document',
fileFormat: definition?.officeMeta?.fileFormat || 'md',
}
}
export function buildOfficeSkillResult(definition, inputText = '') {
const planSteps = (definition?.officeMeta?.planSteps || []).map((step, index) => ({
id: `${definition?.key || 'skill'}-${index + 1}`,
order: index + 1,
title: step,
desc: index === 0 ? '先根据你的输入确认处理口径。' : '生成可继续加工的中间结果。',
}))
const execution = buildOfficeSkillExecution(definition, inputText)
const primaryArtifact = execution?.artifacts?.[0] || null
return {
status: 'ready',
title: execution?.title || definition?.officeMeta?.resultTitle || `${definition?.label || '技能'}结果`,
summary: execution?.summary || definition?.officeMeta?.summary || '',
highlights: execution?.highlights || definition?.officeMeta?.highlights || [],
request: buildOfficeSkillPayload(definition, inputText),
interactive: execution?.interactive || null,
artifacts: Array.isArray(execution?.artifacts) ? execution.artifacts : [],
artifact: {
type: primaryArtifact?.kind || definition?.officeMeta?.artifactType || 'document',
title: primaryArtifact?.title || execution?.title || definition?.officeMeta?.resultTitle || definition?.label || '技能结果',
fileFormat: primaryArtifact?.fileFormat || definition?.officeMeta?.fileFormat || 'md',
preview: primaryArtifact?.content || inputText || definition?.officeMeta?.defaultPrompt || '',
},
taskPlan: {
title: definition?.officeMeta?.taskPlanTitle || `${definition?.label || '技能'}流程`,
steps: planSteps,
},
}
}
export async function executeOfficeSkill(context = {}) {
const definition = context?.definition || {}
const inputText = String(context?.inputText || '').trim()
const fallbackInput = inputText || definition?.officeMeta?.defaultPrompt || ''
const taskId = Number(context?.taskId || 0)
if (taskId > 0 && definition?.key) {
const res = await executeOfficeSkillRequest({
task_id: taskId,
skill_key: definition.key,
input_text: fallbackInput,
mode: context?.mode || '',
})
const result = res?.data?.result
if (result) return result
}
return buildOfficeSkillResult(definition, fallbackInput)
}
@@ -1,355 +0,0 @@
import { SKILL_SOURCES } from './types'
import {
buildComposerBlocks,
hasSendableContent,
serializeBlocksToText,
} from './runtime'
import { buildOfficeSkillExecution } from './officeArtifacts'
import { executeOfficeSkill as executeOfficeSkillRequest } from '@/api/officeSkill'
export const officeSkillCatalog = [
{
id: 'eai.ppt-generation',
code: 'EAI-T-201',
key: 'ppt-generation',
label: 'PPT 生成',
category: 'document',
artifactType: 'presentation',
fileFormat: 'pptx',
description: '根据主题、受众和材料,先整理演示逻辑,再生成可继续细化的演示文稿骨架。',
defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲',
resultTitle: '演示文稿骨架',
summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。',
highlights: ['适合周报、汇报、方案、复盘', '会先拆页面逻辑,再组织每页要点', '默认沉淀演示大纲与讲稿备注'],
taskPlanTitle: 'PPT 生成流程',
planSteps: ['确认汇报目标', '规划页面结构', '提炼每页要点', '整理讲稿备注'],
},
{
id: 'eai.mind-map',
code: 'EAI-T-202',
key: 'mind-map',
label: '思维导图',
category: 'structure',
artifactType: 'mindmap',
fileFormat: 'svg',
description: '把一句话主题、会议纪要或长文本快速展开成结构化导图,适合梳理任务与知识结构。',
defaultPrompt: '把这次项目启动会整理成一版思维导图',
resultTitle: '导图结构草案',
summary: '已把输入内容压缩成层级结构,方便继续扩展节点或导出成导图文件。',
highlights: ['适合任务拆解与知识梳理', '会优先生成主干与分支', '默认保留可继续编辑的导图源结构'],
taskPlanTitle: '思维导图生成流程',
planSteps: ['识别主题主线', '拆分一级分支', '补齐关键节点', '整理导图源结构'],
},
{
id: 'eai.longform-writing',
code: 'EAI-T-203',
key: 'longform-writing',
label: '长文创作',
category: 'writing',
artifactType: 'document',
fileFormat: 'docx',
description: '统一承接论文、方案、制度文档、公众号长文等长文任务,先搭大纲,再分段生成正文。',
defaultPrompt: '围绕企业 AI 落地写一篇完整方案',
resultTitle: '长文草稿',
summary: '已按长文写作流程生成大纲与正文骨架,适合继续细化为正式稿。',
highlights: ['支持论文、方案、制度、长文', '先搭结构再出正文', '默认沉淀大纲、摘要和正文稿'],
taskPlanTitle: '长文创作流程',
planSteps: ['明确文体与受众', '规划章节大纲', '生成正文骨架', '整理摘要与交付稿'],
},
{
id: 'eai.text-toolkit',
code: 'EAI-T-204',
key: 'text-toolkit',
label: '文本处理',
category: 'text',
artifactType: 'document',
fileFormat: 'md',
description: '用于去 Markdown 符号、格式整理、文本统计与批量清洗,让复制出来的内容更适合继续使用。',
defaultPrompt: '把这段 AI 回答清洗成适合粘贴到 Word 的纯文本',
resultTitle: '清洗后文本',
summary: '已按文本处理工具箱模式整理好内容,方便继续复制、统计或二次编辑。',
highlights: ['去 Markdown 噪音', '统一段落和标点', '适合高频复制粘贴场景'],
taskPlanTitle: '文本处理流程',
planSteps: ['识别清洗目标', '去除噪音符号', '统一格式与标点', '输出可复用文本'],
},
{
id: 'eai.ocr-understanding',
code: 'EAI-T-205',
key: 'ocr-understanding',
label: 'OCR / 图文理解',
category: 'vision',
artifactType: 'document',
fileFormat: 'md',
description: '识别图片、截图和扫描件中的文字与结构信息,并提炼为摘要或结构化字段。',
defaultPrompt: '识别这张截图里的文字并整理成结构化摘要',
resultTitle: '识别结果摘要',
summary: '已按图文理解流程准备好识别与整理结果的第一版结构,方便继续抽字段或生成摘要。',
highlights: ['适合截图、扫描件、表格照片', '支持文字识别与结构摘要', '默认沉淀识别结果和字段草案'],
taskPlanTitle: '图文理解流程',
planSteps: ['识别图像内容', '抽取文字与字段', '整理结构摘要', '输出可复用结果'],
},
{
id: 'eai.meeting-minutes',
code: 'EAI-T-206',
key: 'meeting-minutes',
label: '会议纪要',
category: 'document',
artifactType: 'document',
fileFormat: 'md',
description: '把会议记录、聊天记录和零散讨论整理成正式纪要、决议摘要和待办清单。',
defaultPrompt: '把这段项目例会记录整理成正式会议纪要',
resultTitle: '会议纪要草稿',
summary: '已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。',
highlights: ['自动提炼结论与待办', '适合例会、评审会和复盘会', '默认沉淀纪要正文和行动项清单'],
taskPlanTitle: '会议纪要整理流程',
planSteps: ['识别会议主题', '提炼关键结论', '整理行动项', '输出同步版本'],
},
{
id: 'eai.project-planning',
code: 'EAI-T-207',
key: 'project-planning',
label: '项目规划',
category: 'planning',
artifactType: 'plan',
fileFormat: 'md',
description: '根据目标、约束和现状整理项目阶段、里程碑、执行分工和风险清单。',
defaultPrompt: '为新产品上线项目整理一版执行计划和里程碑',
resultTitle: '项目规划草案',
summary: '已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。',
highlights: ['适合启动、排期和推进对齐', '默认沉淀阶段计划与风险清单', '结果适合继续流转到任务治理'],
taskPlanTitle: '项目规划流程',
planSteps: ['明确目标边界', '拆解阶段路径', '整理里程碑', '补齐风险与依赖'],
},
{
id: 'eai.email-drafting',
code: 'EAI-T-208',
key: 'email-drafting',
label: '邮件起草',
category: 'communication',
artifactType: 'document',
fileFormat: 'txt',
description: '把沟通意图、背景和要求整理成可直接发送的正式邮件,并附带简短跟进版本。',
defaultPrompt: '帮我起草一封推进客户回款的正式邮件',
resultTitle: '邮件草稿',
summary: '已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。',
highlights: ['适合汇报、催办、确认和通知', '兼顾正式语气与清晰表达', '默认沉淀长邮件和短消息版本'],
taskPlanTitle: '邮件起草流程',
planSteps: ['识别沟通目的', '整理背景与重点', '生成正式邮件', '补充简短跟进版本'],
},
{
id: 'eai.table-cleanup',
code: 'EAI-T-209',
key: 'table-cleanup',
label: '表格整理',
category: 'data',
artifactType: 'table',
fileFormat: 'md',
description: '把零散字段、半结构化列表和杂乱数据整理成清晰表格,并补出字段说明与清洗备注。',
defaultPrompt: '把这批客户跟进记录整理成结构化表格',
resultTitle: '表格整理结果',
summary: '已把输入内容整理成结构化表格,并补了字段说明和清洗备注。',
highlights: ['适合名单、台账、记录整理', '默认沉淀表格正文和字段说明', '便于继续导入 Excel 或系统'],
taskPlanTitle: '表格整理流程',
planSteps: ['识别字段结构', '清洗原始数据', '整理表格内容', '补充字段说明'],
},
{
id: 'eai.proposal-summary',
code: 'EAI-T-210',
key: 'proposal-summary',
label: '方案摘要',
category: 'summary',
artifactType: 'document',
fileFormat: 'md',
description: '把长方案、材料包和售前文档压缩成高密度摘要,保留核心结论、价值点和实施建议。',
defaultPrompt: '把这份解决方案压成一版管理层可读摘要',
resultTitle: '方案摘要稿',
summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。',
highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'],
taskPlanTitle: '方案摘要流程',
planSteps: ['识别方案目标', '提炼核心亮点', '压缩实施路径', '输出摘要版本'],
},
{
id: 'eai.progress-report',
code: 'EAI-T-211',
key: 'progress-report',
label: '周报 / 日报',
category: 'report',
artifactType: 'document',
fileFormat: 'md',
description: '把工作记录、聊天记录和事项进展整理成日报、周报或阶段同步,补齐风险和下步计划。',
defaultPrompt: '根据这些工作记录整理一版项目周报',
resultTitle: '进展汇报草稿',
summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。',
highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'],
taskPlanTitle: '进展汇报流程',
planSteps: ['归并本期工作', '提炼关键进展', '整理风险事项', '输出汇报版本'],
},
{
id: 'eai.contract-brief',
code: 'EAI-T-212',
key: 'contract-brief',
label: '合同要点提炼',
category: 'document',
artifactType: 'summary',
fileFormat: 'md',
description: '把合同、协议和条款材料压缩成核心要点、风险提示和待确认事项,方便业务快速预读。',
defaultPrompt: '把这份采购合同提炼成要点摘要和待确认项',
resultTitle: '合同要点摘要',
summary: '已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。',
highlights: ['适合采购、合作、服务类合同', '自动压缩核心条款与风险点', '默认沉淀摘要、风险和待确认项'],
taskPlanTitle: '合同要点提炼流程',
planSteps: ['识别合同结构', '提炼核心条款', '标记风险事项', '输出摘要版本'],
},
{
id: 'eai.interview-summary',
code: 'EAI-T-213',
key: 'interview-summary',
label: '访谈整理',
category: 'summary',
artifactType: 'document',
fileFormat: 'md',
description: '把访谈记录、用户调研和深度沟通内容整理成主题摘要、洞察结论和后续动作。',
defaultPrompt: '把这份用户访谈记录整理成主题摘要和洞察结论',
resultTitle: '访谈整理稿',
summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。',
highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'],
taskPlanTitle: '访谈整理流程',
planSteps: ['归并访谈内容', '提炼主题观点', '总结关键洞察', '输出整理版本'],
},
{
id: 'eai.policy-rewrite',
code: 'EAI-T-214',
key: 'policy-rewrite',
label: '制度改写',
category: 'writing',
artifactType: 'document',
fileFormat: 'md',
description: '把制度、规范和说明文本重写成更清楚、更统一的正式版本,并补出修订说明。',
defaultPrompt: '把这份报销制度改写成更正式清晰的版本',
resultTitle: '制度改写稿',
summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。',
highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'],
taskPlanTitle: '制度改写流程',
planSteps: ['识别制度目标', '统一结构口径', '重写正式正文', '补充修订说明'],
},
{
id: 'eai.survey-summary',
code: 'EAI-T-215',
key: 'survey-summary',
label: '表单问卷汇总',
category: 'report',
artifactType: 'report',
fileFormat: 'md',
description: '把问卷、报名表和收集表单结果整理成汇总报告、重点发现和后续动作建议。',
defaultPrompt: '把这批培训反馈问卷整理成汇总结论和改进建议',
resultTitle: '问卷汇总稿',
summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。',
highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'],
taskPlanTitle: '问卷汇总流程',
planSteps: ['归并表单结果', '提炼重点发现', '整理问题与建议', '输出汇总版本'],
},
]
function createOfficeSkillDefinition(options) {
return {
id: String(options?.id || `eai.${options?.key || 'office-skill'}`),
code: String(options?.code || '').trim(),
source: SKILL_SOURCES.EAI,
key: String(options?.key || '').trim(),
label: String(options?.label || '').trim(),
kind: 'skill',
icon: options?.icon || 'skill',
description: options?.description || '',
officeMeta: {
category: options?.category || 'office',
artifactType: options?.artifactType || 'document',
fileFormat: options?.fileFormat || 'md',
defaultPrompt: options?.defaultPrompt || '',
resultTitle: options?.resultTitle || options?.label || '技能结果',
summary: options?.summary || '',
highlights: Array.isArray(options?.highlights) ? options.highlights : [],
taskPlanTitle: options?.taskPlanTitle || `${options?.label || '技能'}流程`,
planSteps: Array.isArray(options?.planSteps) ? options.planSteps : [],
},
executor: options?.executor,
resultCard: options?.resultCard,
}
}
function buildOfficeSkillPayload(definition, inputText = '') {
const blocks = buildComposerBlocks({
resource: {
key: definition?.key,
label: definition?.label,
uri: `skill://${definition?.key}`,
description: definition?.description,
skillId: definition?.key,
icon: definition?.icon || 'skill',
},
text: inputText,
})
return {
blocks,
plainText: serializeBlocksToText(blocks),
sendable: hasSendableContent(blocks),
artifactType: definition?.officeMeta?.artifactType || 'document',
fileFormat: definition?.officeMeta?.fileFormat || 'md',
}
}
function buildOfficeSkillResult(definition, inputText = '') {
const planSteps = (definition?.officeMeta?.planSteps || []).map((step, index) => ({
id: `${definition?.key || 'skill'}-${index + 1}`,
order: index + 1,
title: step,
desc: index === 0 ? '先根据你的输入确认处理口径。' : '生成可继续加工的中间结果。',
}))
const execution = buildOfficeSkillExecution(definition, inputText)
const primaryArtifact = execution?.artifacts?.[0] || null
return {
status: 'ready',
title: execution?.title || definition?.officeMeta?.resultTitle || `${definition?.label || '技能'}结果`,
summary: execution?.summary || definition?.officeMeta?.summary || '',
highlights: execution?.highlights || definition?.officeMeta?.highlights || [],
request: buildOfficeSkillPayload(definition, inputText),
interactive: execution?.interactive || null,
artifacts: Array.isArray(execution?.artifacts) ? execution.artifacts : [],
artifact: {
type: primaryArtifact?.kind || definition?.officeMeta?.artifactType || 'document',
title: primaryArtifact?.title || execution?.title || definition?.officeMeta?.resultTitle || definition?.label || '技能结果',
fileFormat: primaryArtifact?.fileFormat || definition?.officeMeta?.fileFormat || 'md',
preview: primaryArtifact?.content || inputText || definition?.officeMeta?.defaultPrompt || '',
},
taskPlan: {
title: definition?.officeMeta?.taskPlanTitle || `${definition?.label || '技能'}流程`,
steps: planSteps,
},
}
}
async function executeOfficeSkill(context = {}) {
const inputText = String(context?.inputText || '').trim()
const fallbackInput = inputText || context.definition?.officeMeta?.defaultPrompt || ''
const taskId = Number(context?.taskId || 0)
if (taskId > 0 && context?.definition?.key) {
const res = await executeOfficeSkillRequest({
task_id: taskId,
skill_key: context.definition.key,
input_text: fallbackInput,
mode: context?.mode || '',
})
const result = res?.data?.result
if (result) return result
}
return buildOfficeSkillResult(context.definition, fallbackInput)
}
export function createOfficeSkillRegistryDefinitions(resultCard) {
return officeSkillCatalog.map((item) =>
createOfficeSkillDefinition({
...item,
executor: executeOfficeSkill,
resultCard,
})
)
}
@@ -18,7 +18,7 @@ export const workbuddyReplicaSettingsSchema = Object.freeze([
},
])
export const workbuddyReplicaCapabilitySchema = Object.freeze({
export const workbuddyReplicaRuntimeSchema = Object.freeze({
inputBlocks: ['resource_link', 'text'],
sendProtocol: 'content-blocks',
executionMode: 'workbench-attached',
@@ -5,7 +5,7 @@ import {
serializeBlocksToText,
} from './runtime'
import {
workbuddyReplicaCapabilitySchema,
workbuddyReplicaRuntimeSchema,
workbuddyReplicaSettingsSchema,
} from './schema'
@@ -22,7 +22,7 @@ export function createWorkBuddyReplicaSkillDefinition(options) {
icon: options?.icon || 'skill',
description: options?.description || '',
replicaSource: 'workbuddy',
capabilitySchema: options?.capabilitySchema || workbuddyReplicaCapabilitySchema,
runtimeSchema: options?.runtimeSchema || workbuddyReplicaRuntimeSchema,
settings: options?.settings || workbuddyReplicaSettingsSchema,
workbuddyMeta: {
skillId: options?.workbuddySkillId || key,
@@ -1,7 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { listSkills } from '@/api/skill'
import { staticSkillCatalog as staticSkills } from '@/config/workbench'
import { listSkills } from '@/skills/api/skill'
import { getStaticSkillByKey } from '@/skills/core/builtin'
function safeParse(value, fallback) {
if (value == null || value === '') return fallback
@@ -65,9 +65,9 @@ function normalizeWorkflowSchema(remote, fallback) {
return []
}
function normalizeCapabilityTags(remote, fallback) {
if (Array.isArray(fallback?.capabilityTags) && fallback.capabilityTags.length) {
return fallback.capabilityTags
function normalizeSkillTags(remote, fallback) {
if (Array.isArray(fallback?.skillTags) && fallback.skillTags.length) {
return fallback.skillTags
}
const ontology = safeParse(remote.ontology_binding_json, {})
return [
@@ -77,7 +77,7 @@ function normalizeCapabilityTags(remote, fallback) {
}
function normalizeSkill(item) {
const fallback = staticSkills.find((skill) => skill.key === item?.key) || null
const fallback = getStaticSkillByKey(item?.key)
const starterPrompts = safeParse(item?.starter_prompts_json, [])
const remote = {
...item,
@@ -99,7 +99,7 @@ function normalizeSkill(item) {
}
return {
...remote,
capabilityTags: normalizeCapabilityTags(item, fallback),
skillTags: normalizeSkillTags(item, fallback),
workflowSchema: normalizeWorkflowSchema(item, fallback),
artifactSchema: normalizeArtifactSchema(item?.artifact_schema_json, fallback),
interactionCard: buildFallbackInteractionCard(remote, fallback),
@@ -3,7 +3,7 @@ import { computed, ref } from 'vue'
import {
defaultsForSkill,
settingsForSkill,
} from '@/config/skillSettings'
} from '@/skills/config/skillSettings'
/**
* 技能设置的值。
@@ -12,8 +12,8 @@ import {
* 这样「恢复默认」就是删一条记录,「有没有改过」就是看这张表空不空 ——
* 不用拿两份完整对象去逐字段比。
*
* 为什么值是前端独占的:决策 5(设置跟着任务存进 worker_task)后端还没做,
* worker_task 表上没有技能和参数字段。所以这些值活不过刷新,也不进任何请求体 ——
* 为什么值是前端独占的:决策 5(设置跟着任务存进 task_record)后端还没做,
* task_record 表上没有技能和参数字段。所以这些值活不过刷新,也不进任何请求体 ——
* 技能页的 handleSend 是拿 valuesOf(SKILL) 现取一份发给后端的,后端不留。
* 这一点 SkillStrip 的脚注要写明,别让用户以为改了就存下来了。
*
@@ -1,4 +1,4 @@
import http from './http'
import http from '@/api/http'
export function listSpecialists(params) {
return http.get('/specialists', { params })
@@ -0,0 +1,51 @@
export const SPECIALIST_WORKBENCH_ROUTE = '/home'
export function createInteractionCard({
greeting,
tagline,
openingPrompt,
relationshipToUser = '',
tone = '专业、直接、可执行',
starterPrompts = [],
} = {}) {
return {
greeting,
tagline,
openingPrompt,
relationshipToUser,
tone,
starterPrompts,
}
}
export function createSpecialistManifest({
key,
label,
tier,
specialistMode,
marketTag = '已安装',
objectEntryRoute = SPECIALIST_WORKBENCH_ROUTE,
summary = '',
status = '',
risk = '',
color = '',
stage = '',
progress = 0,
interactionCard = {},
}) {
return {
key,
label,
tier,
specialistMode,
marketTag,
objectEntryRoute,
summary,
status,
risk,
color,
stage,
progress,
interactionCard: createInteractionCard(interactionCard),
}
}
@@ -0,0 +1,70 @@
import { withObjectCodes } from '@/utils/objectCode'
import { createRegistry } from '@/specialists/core/registry'
import contractReviewManifest from '@/specialists/packages/contract-review/manifest'
import solutionProposalManifest from '@/specialists/packages/solution-proposal/manifest'
import trainingDeliveryManifest from '@/specialists/packages/training-delivery/manifest'
import knowledgeOperationsManifest from '@/specialists/packages/knowledge-operations/manifest'
import processCoordinationManifest from '@/specialists/packages/process-coordination/manifest'
import reportGenerationManifest from '@/specialists/packages/report-generation/manifest'
import wechatOfficialAccountManifest from '@/specialists/packages/wechat-official-account/manifest'
import logisticsFulfillmentManifest from '@/specialists/packages/logistics-fulfillment/manifest'
import hrEmailSorterManifest from '@/specialists/packages/hr-email-sorter/manifest'
import resumeProcessorManifest from '@/specialists/packages/resume-processor/manifest'
import presentationBriefingManifest from '@/specialists/packages/presentation-briefing/manifest'
const builtinSpecialistRegistry = createRegistry([
contractReviewManifest,
solutionProposalManifest,
trainingDeliveryManifest,
knowledgeOperationsManifest,
processCoordinationManifest,
reportGenerationManifest,
wechatOfficialAccountManifest,
logisticsFulfillmentManifest,
hrEmailSorterManifest,
resumeProcessorManifest,
presentationBriefingManifest,
])
export function getBuiltinSpecialistRegistry() {
return builtinSpecialistRegistry
}
export const staticSpecialistCatalog = withObjectCodes(
builtinSpecialistRegistry.manifests(),
{ kind: 'specialist' },
)
export function getSpecialistsByTier(tier) {
return staticSpecialistCatalog.filter((specialist) => specialist.tier === tier)
}
export function getInstalledSpecialistCount() {
return staticSpecialistCatalog.length
}
export function getUpgradeableSpecialistCount() {
return staticSpecialistCatalog.filter((specialist) => specialist.marketTag === '可升级').length
}
export function getSpecialistStats() {
return {
total: getInstalledSpecialistCount(),
generic: getSpecialistsByTier('generic').length,
industry: getSpecialistsByTier('industry').length,
dw: staticSpecialistCatalog.filter((specialist) => specialist.specialistMode === 'dw').length,
adw: staticSpecialistCatalog.filter((specialist) => specialist.specialistMode === 'adw').length,
upgradeable: getUpgradeableSpecialistCount(),
}
}
export function getMarketItemsByTier(tier) {
return getSpecialistsByTier(tier).map((specialist) => ({
key: specialist.key,
title: specialist.label,
tag: specialist.marketTag || '已安装',
desc: specialist.summary,
meta: `当前阶段 ${specialist.stage} · ${specialist.status} · ${specialist.risk}`,
objectEntryRoute: specialist.objectEntryRoute,
}))
}
@@ -0,0 +1,140 @@
function normalizeArray(values) {
return (Array.isArray(values) ? values : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
}
function splitKeywords(value) {
return String(value || '')
.split(/[\/、,,和与·]/)
.map((item) => item.trim())
.filter((item) => item && item.length <= 12)
}
function buildTags(...sources) {
const collected = sources
.flatMap((source) => splitKeywords(source))
.filter(Boolean)
return [...new Set(collected)].slice(0, 6)
}
function parseAllowedSkills(raw) {
if (Array.isArray(raw)) return raw.map((item) => String(item || '').trim()).filter(Boolean)
const text = String(raw || '').trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed)
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
return []
}
}
function splitChineseList(raw) {
return String(raw || '')
.split(/[、,,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
export function buildSpecialistCatalogCards(specialists = []) {
return specialists.map((specialist) => ({
key: specialist.key,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
title: specialist.label,
badge: specialist.marketTag,
kind: specialist.specialistMode === 'adw' ? '编排型专员' : '执行型专员',
desc: specialist.interactionCard?.tagline || specialist.summary,
color: specialist.color,
installState: specialist.marketTag === '已安装' ? 'installed' : 'pending',
meta: [],
config: [],
searchTokens: [
specialist.tier === 'industry' ? '行业专员' : '通用专员',
specialist.specialistMode === 'adw' ? '编排协作' : '执行协作',
specialist.marketTag,
specialist.tier,
specialist.specialistMode,
].filter(Boolean),
}))
}
export function getSpecialistCatalogMeta() {
return {
title: '专员目录',
description: '这里收口平台内建专员与可升级目录项。你可以按对象方式浏览、检索、查看职责边界,并继续进入专员详情。',
searchPlaceholder: '搜索专员名称、定位或能力标签',
}
}
export function getSpecialistSectionLabels() {
return {
installedTitle: '已上岗的专员',
installedDesc: '这些专员已经上岗,可直接查看其对象定义与详情。',
installedEmpty: '暂无已上岗的专员',
pendingTitle: '待上岗的专员',
pendingDesc: '这些专员还未上岗,包括可升级与试用目录项。',
pendingEmpty: '暂无待上岗的专员',
}
}
export function buildSpecialistUseCase(specialist) {
const openingPrompt = String(specialist?.interactionCard?.openingPrompt || '').trim()
if (openingPrompt) {
return `当你已经有目标、材料,或者已经知道当前卡点时,就适合找它介入。${openingPrompt}`
}
return '当你想找一个专门的对象来协助推进、整理和交付时,就适合让它介入。'
}
export function buildSpecialistExtraSections(remote, { lookupSkillLabel } = {}) {
if (!remote) return []
const sections = []
const skillKeys = parseAllowedSkills(remote.allowed_skills)
if (skillKeys.length) {
sections.push({
title: '它会调用的技能',
items: skillKeys.map((key) => {
const label = typeof lookupSkillLabel === 'function' ? lookupSkillLabel(key) : ''
return label ? `${label}(${key})` : key
}),
})
}
const infoSources = splitChineseList(remote.info_sources)
if (infoSources.length) {
sections.push({ title: '它读哪些信源', items: infoSources })
}
const permissions = splitChineseList(remote.permission_scope)
if (permissions.length) {
sections.push({ title: '它可以动什么', items: permissions })
}
return sections
}
export function buildSpecialistDetail(specialist, remote, { lookupSkillLabel } = {}) {
return {
title: specialist.label,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
subtitle: specialist.interactionCard?.relationshipToUser || '你的协作搭档',
metaText: `${specialist.tier === 'industry' ? '行业专员' : '通用专员'} · ${specialist.specialistMode === 'adw' ? '流程推进型' : '执行处理型'} · ${specialist.marketTag || '可用'}`,
badge: '专员',
avatar: specialist.label.slice(0, 1),
color: specialist.color || '#409eff',
description:
specialist.interactionCard?.greeting ||
`这个专员主要负责 ${specialist.interactionCard?.tagline || specialist.label},可以陪你把事情往前推进。`,
tags: buildTags(specialist.interactionCard?.tagline, specialist.summary),
sceneTitle: '适合什么时候用',
sceneText: buildSpecialistUseCase(specialist),
extraSections: buildSpecialistExtraSections(remote, { lookupSkillLabel }),
promptTitle: '专员帮你做',
prompts: normalizeArray(specialist.interactionCard?.starterPrompts),
}
}
@@ -0,0 +1,13 @@
export function createRegistry(items = []) {
const manifests = items.filter((item) => item && item.key)
const manifestMap = Object.fromEntries(manifests.map((item) => [item.key, item]))
return {
manifests() {
return manifests.map((item) => ({ ...item }))
},
byKey(key) {
return manifestMap[String(key || '').trim()] || null
},
}
}
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'contract-review',
label: '合同审查专员',
tier: 'industry',
specialistMode: 'adw',
marketTag: '已安装',
summary: '华东客户主协议修订',
status: '2 个待确认',
risk: '1 个高风险',
color: '#f56c6c',
stage: '人工复核',
progress: 72,
interactionCard: {
greeting: '我是合同审查专员,已经准备好帮你定位条款风险、整理红线建议,并输出可交付的审查结果。',
tagline: '把合同风险、红线和交付意见整理清楚。',
openingPrompt: '把合同发给我,或者直接告诉我这次要重点盯哪些条款。',
relationshipToUser: '你的法务协作搭档',
starterPrompts: [
'审查这份采购合同的风险点',
'帮我整理一版 redline 建议',
'重点看赔偿责任和终止条款',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'hr-email-sorter',
label: 'HR 邮件整理专员',
tier: 'industry',
specialistMode: 'dw',
marketTag: '试用',
summary: '识别并整理招聘邮箱中的简历邮件',
status: '3 封待处理',
risk: '0 个异常',
color: '#409eff',
stage: '邮件处理',
progress: 0,
interactionCard: {
greeting: '我是 HR 邮件整理专员,可以帮你识别、分类并整理招聘邮箱里的邮件。',
tagline: '把招聘邮箱里的内容收成可继续处理的人才线索。',
openingPrompt: '把邮件内容或整理目标告诉我,我先帮你分组归档。',
relationshipToUser: '你的招聘整理协作搭档',
starterPrompts: [
'帮我整理今天收到的招聘邮件',
'把这些邮件按岗位和优先级分类',
'先筛出适合继续跟进的候选人邮件',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'knowledge-operations',
label: '知识运营专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '对象字典、证据索引与规则同步',
status: '4 个待整理',
risk: '1 个映射待确认',
color: '#8e6cf2',
stage: '证据整理',
progress: 63,
interactionCard: {
greeting: '我是知识运营专员,可以帮你整理对象字典、证据索引和规则映射。',
tagline: '把散的知识资产收成可查、可用、可治理的结构。',
openingPrompt: '告诉我这次要整理哪类知识资产,我先帮你搭索引。',
relationshipToUser: '你的知识治理协作搭档',
starterPrompts: [
'帮我整理这一批知识条目和标签',
'把这些规则映射成对象字典',
'先盘点一下这组资料的证据索引结构',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'logistics-fulfillment',
label: '履约跟单专员',
tier: 'industry',
specialistMode: 'adw',
marketTag: '试用',
summary: '美西航线本周履约看板',
status: '1 个异常',
risk: '2 个节点延迟',
color: '#e6a23c',
stage: '异常处置',
progress: 49,
interactionCard: {
greeting: '我是履约跟单专员,可以帮你跟进节点、识别异常并整理处置建议。',
tagline: '把履约过程里的异常、催办和交付节奏收在一条线上。',
openingPrompt: '把当前订单、节点和异常情况发给我,我先帮你排查。',
relationshipToUser: '你的履约协作搭档',
starterPrompts: [
'帮我梳理这票货当前的履约状态',
'把这些延迟节点整理成跟单清单',
'针对这次异常给我一版处置建议',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'presentation-briefing',
label: '汇报专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '经营汇报、方案汇报与培训课件统一出稿',
status: '2 个待成稿',
risk: '1 个页数待压缩',
color: '#4f46e5',
stage: '演示编排',
progress: 68,
interactionCard: {
greeting: '我是汇报专员,可以先判断汇报对象和页数,再把材料压成能讲、能交、能继续细化的演示骨架。',
tagline: '把散材料收成一版结构清楚、讲得出去的汇报 PPT。',
openingPrompt: '告诉我汇报对象、场景、目标和页数,我先帮你规划页面结构。',
relationshipToUser: '你的汇报交付协作搭档',
starterPrompts: [
'帮我做一版 8 页管理层汇报 PPT',
'把这份方案压成可讲的客户汇报结构',
'根据这批材料生成培训课件骨架和讲稿备注',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'process-coordination',
label: '流程推进专员',
tier: 'generic',
specialistMode: 'adw',
marketTag: '可升级',
summary: '跨部门催办、checkpoint 跟进与阻塞升级',
status: '6 个待跟进',
risk: '2 个阻塞',
color: '#14b8a6',
stage: '阻塞清理',
progress: 57,
interactionCard: {
greeting: '我是流程推进专员,可以帮你梳理 checkpoint、跟进阻塞并推动事项落地。',
tagline: '把多方协作里的待办、阻塞和升级路径理清楚。',
openingPrompt: '把当前事项、参与方和卡点告诉我,我先帮你排推进节奏。',
relationshipToUser: '你的流程推进协作搭档',
starterPrompts: [
'帮我梳理这个项目的推进 checkpoint',
'把当前阻塞整理成催办清单',
'根据这些会议纪要生成后续跟进事项',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'report-generation',
label: '报告生成专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '可升级',
summary: '日报、周报、复盘和交付说明自动生成',
status: '3 个待输出',
risk: '0 个异常',
color: '#ec4899',
stage: '报告汇总',
progress: 76,
interactionCard: {
greeting: '我是报告生成专员,可以把过程材料整理成日报、周报、复盘和交付说明。',
tagline: '把分散信息压成结构完整、可直接提交的报告。',
openingPrompt: '把素材贴给我,或者告诉我报告对象、周期和格式要求。',
relationshipToUser: '你的报告整理协作搭档',
starterPrompts: [
'把今天的工作整理成日报',
'根据这些记录生成项目周报',
'帮我输出一版交付复盘说明',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'resume-processor',
label: '简历处理专员',
tier: 'industry',
specialistMode: 'dw',
marketTag: '试用',
summary: '筛选候选人并安排面试',
status: '5 份待筛选',
risk: '1 个匹配待确认',
color: '#67c23a',
stage: '简历筛选',
progress: 0,
interactionCard: {
greeting: '我是简历处理专员,可以帮你筛选候选人、整理匹配理由并安排后续动作。',
tagline: '把简历筛选从海量阅读变成结构化判断。',
openingPrompt: '告诉我岗位要求和筛选标准,我先帮你做一轮初筛。',
relationshipToUser: '你的招聘筛选协作搭档',
starterPrompts: [
'根据这个 JD 帮我筛选一批候选人',
'把这些简历按匹配度排序',
'帮我整理一版面试建议和待确认点',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'solution-proposal',
label: '售前方案专员',
tier: 'industry',
specialistMode: 'adw',
marketTag: '可升级',
summary: 'A 客户智能培训升级方案',
status: '3 个待办',
risk: '1 个依赖阻塞',
color: '#409eff',
stage: '方案生成',
progress: 58,
interactionCard: {
greeting: '我是售前方案专员,可以帮你把客户背景、需求和方案结构快速组织起来。',
tagline: '把客户需求转成一版可讲、可交付、可评审的方案。',
openingPrompt: '告诉我客户背景、目标和限制条件,我先帮你起方案骨架。',
relationshipToUser: '你的售前协作搭档',
starterPrompts: [
'帮我起一版智能培训升级方案',
'先整理这个客户的需求要点和方案结构',
'把这份需求纪要转成售前方案大纲',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'training-delivery',
label: '培训交付专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '新人销售训练营第 3 周',
status: '5 个进行中',
risk: '0 个异常',
color: '#67c23a',
stage: '交付归档',
progress: 84,
interactionCard: {
greeting: '我是培训交付专员,可以帮你推进训练计划、交付节点和归档结果。',
tagline: '把培训交付从排期、推进到归档串成闭环。',
openingPrompt: '把交付背景、学员范围和目标发给我,我来帮你推进。',
relationshipToUser: '你的培训交付协作搭档',
starterPrompts: [
'帮我整理这期训练营的交付计划',
'把这些交付节点汇总成执行清单',
'根据培训记录生成交付归档说明',
],
},
})
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'wechat-official-account',
label: '公众号助手',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '热点选题、标题、提纲、正文四步创作工作流',
status: '4 个节点可执行',
risk: '0 个异常',
color: '#2563eb',
stage: '正文创作',
progress: 0,
interactionCard: {
greeting: '我是公众号助手,可以帮你把选题、提纲、正文和发布准备串起来。',
tagline: '把内容创作流程从想法推进到可发布。',
openingPrompt: '告诉我主题、受众和语气,我先帮你起草内容结构。',
relationshipToUser: '你的内容创作协作搭档',
starterPrompts: [
'围绕这个热点起 3 个公众号选题',
'帮我写一版文章提纲和开头',
'把这篇草稿润成适合公众号发布的正文',
],
},
})
@@ -9,10 +9,10 @@
// 由种子字段派生,每项 11 个键(action_name/action_type/trigger_mode/input_sources/
// expected_output/risk_level/status/approval_state/operator/started_at/finished_at)。
// 注意:**不含 action_key**,所以下面一律用 normalizeActionKey(action_name) 兜底。
// worker_task_detail.runs ← GET /api/worker-tasks/:id
// task_detail.runs ← GET /api/tasks/:id
// action_key 是后端 normalizeWorkerActionKey() 由 action_title 派生的,两边规则一致。
/** 动作状态词表。ready/pending 是种子给的,running/done 是 worker_run 给的。 */
/** 动作状态词表。ready/pending 是种子给的,running/done 是 task_run 给的。 */
export function statusText(value = '') {
const map = {
ready: '可执行',
@@ -72,7 +72,7 @@ export function getActionType(title = '') {
/**
* 动作名 → 连接键。必须与后端 normalizeWorkerActionKey()
* (backend-go/internal/api/worker_task.go) 规则一致:空格 / \ · _ 都折成 “-”,
* (backend-go/internal/api/task_runtime.go) 规则一致:空格 / \ · _ 都折成 “-”,
* 小写,压缩连续 “-”。两边只要有一边改了,配对就会静默断链。
*/
export function normalizeActionKey(value = '') {
@@ -110,7 +110,7 @@ function findLatestRun(runs = [], actionKey = '', actionTitle = '') {
// ---------------------------------------------------------------------------
// 推断的「进行中」——这一段在代码里明确是推断,不是后端上报的事实。
//
// worker_run.status 在后端恒为 "done"(backend-go/internal/api/worker_task.go 写入
// task_run.status 在后端恒为 "done"(backend-go/internal/api/task_runtime.go 写入
// 时硬编码),所以「灰虚线 / 蓝 / 绿 / 红」四态里,真实数据只能表达「跑过(绿)」和
// 「没跑过(灰)」。红色要靠 run 真失败,当前写不出;蓝色没有任何字段能表达。
//
@@ -118,7 +118,7 @@ function findLatestRun(runs = [], actionKey = '', actionTitle = '') {
// 视为正在执行。这是**读出来的猜测**,不是执行状态——所以调用方必须能标出它是推断的,
// 不要把蓝框当成进度事实展示给用户。
//
// 任务状态只有四个值(model/worker_task.go 默认「待处理」,setTaskLifecycle 可设为
// 任务状态只有四个值(model/task_record.go 默认「待处理」,setTaskLifecycle 可设为
// 「进行中」「已完成」「已归档」),只有「进行中」意味着流程在推进。
// ---------------------------------------------------------------------------
export const TASK_ACTIVE_STATUS = '进行中'
@@ -157,9 +157,9 @@ export function markFlowState(records = [], task = null) {
* 卡片(workspace.taskCards)按位置对齐。两条路径都用 runs 覆盖 status。
*
* @param {object} p
* @param {object|null} p.specialist 专家对象(含 action_records_json / worker_type)
* @param {object|null} p.specialist 专家对象(含 action_records_json / specialist_mode)
* @param {object} p.snapshot 工作台快照(bottomPanel.trace / outputs / evidence)
* @param {Array} p.runs 当前任务的 worker_run 列表
* @param {Array} p.runs 当前任务的 task_run 列表
* @param {object} p.workspace 工作台描述符(taskCards)
*/
export function buildActionRecords({ specialist = null, snapshot = {}, runs = [], workspace = null } = {}) {
@@ -184,7 +184,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = []
inputs: listText(item.input_sources, '待补充输入信源'),
outputs: listText(item.expected_output, '待补充预期结果'),
approval: item.approval_state === 'approval_required' ? '需要确认' : '可直接执行',
operator: item.operator || (specialist?.worker_type === 'adw' ? 'ADW' : 'DW'),
operator: item.operator || (specialist?.specialist_mode === 'adw' ? 'ADW' : 'DW'),
lastRunSummary: output.summary || '',
// —— 给流程视图用的原始事实,不参与上面那些字段的渲染 ——
// hasRun 决定灰框还是绿框(见 markFlowState)。
@@ -209,7 +209,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = []
actionKey,
title: actionTitle,
type: getActionType(task.title),
triggerMode: specialist?.worker_type === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助',
triggerMode: specialist?.specialist_mode === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助',
risk: getRiskLevel(tone),
status: run ? statusText(run.status) : task.status,
tone,
@@ -217,7 +217,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = []
inputs: (snapshot?.evidence || []).slice(0, 2).join(' / ') || '待补充输入信源',
outputs: outputs[index] || outputs[0] || '待补充预期结果',
approval: tone === 'warning' || tone === 'danger' ? '需要确认' : '可直接执行',
operator: specialist?.worker_type === 'adw' ? 'ADW' : 'DW',
operator: specialist?.specialist_mode === 'adw' ? 'ADW' : 'DW',
trace: traces[index] || '',
lastRunSummary: output.summary || '',
hasRun: Boolean(run),
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { listSpecialists } from '@/api/specialist'
import { listSpecialists } from '@/specialists/api/specialist'
import { staticSpecialistCatalog } from '@/specialists/core/builtin'
function safeParseObject(value, fallback = {}) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
@@ -24,7 +25,7 @@ function normalizeSpecialist(item) {
display_code: String(item?.display_code || '').trim(),
eailogic_code: String(item?.eailogic_code || '').trim(),
tier: String(item?.tier || '').trim(),
workerType: String(item?.worker_type || item?.workerType || '').trim(),
specialistMode: String(item?.specialist_mode || item?.specialistMode || '').trim(),
objectEntryRoute: String(item?.object_entry_route || item?.objectEntryRoute || '').trim(),
marketTag: String(item?.market_tag || item?.marketTag || '').trim(),
workStatus: String(item?.work_status || item?.workStatus || '').trim(),
@@ -38,7 +39,7 @@ function normalizeSpecialist(item) {
export const useSpecialistCatalogStore = defineStore('specialistCatalog', () => {
const loaded = ref(false)
const loading = ref(false)
const specialists = ref([])
const specialists = ref(staticSpecialistCatalog.map((item) => normalizeSpecialist(item)))
const specialistMap = computed(() =>
Object.fromEntries(specialists.value.map((item) => [item.key, item]))
@@ -56,6 +57,8 @@ export const useSpecialistCatalogStore = defineStore('specialistCatalog', () =>
loaded.value = true
} catch (error) {
console.warn('load specialist catalog failed', error)
specialists.value = staticSpecialistCatalog.map((item) => normalizeSpecialist(item))
loaded.value = true
} finally {
loading.value = false
}

Some files were not shown because too many files have changed in this diff Show More