feat(studio): 补齐剩余工作台聚合接口与真实对接

This commit is contained in:
2026-06-01 05:28:11 +08:00
parent 8f7ffd6cc9
commit ebe0fc5a12
35 changed files with 2092 additions and 123 deletions

View File

@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const getMock = vi.fn();
vi.mock('../request', () => ({
get: getMock,
}));
describe('ingestion api', () => {
afterEach(() => {
getMock.mockReset();
});
it('requests ingestion run aggregate with store and document params', async () => {
getMock.mockResolvedValue({
resultcode: '0',
message: null,
data: { runId: 'run-20260601' },
});
const { getIngestionRun } = await import('../ingestion');
await getIngestionRun('run-20260601', '1001', '11');
expect(getMock).toHaveBeenCalledWith('/knowledge/ingestion-runs/run-20260601', {
params: {
storeId: '1001',
documentId: '11',
},
});
});
});

View File

@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const getMock = vi.fn();
vi.mock('../request', () => ({
get: getMock,
}));
describe('studioDashboard api', () => {
afterEach(() => {
getMock.mockReset();
});
it('requests studio dashboard aggregate', async () => {
getMock.mockResolvedValue({
resultcode: '0',
message: null,
data: { projectName: 'Common Agent Studio' },
});
const { getStudioDashboard } = await import('../studioDashboard');
await getStudioDashboard();
expect(getMock).toHaveBeenCalledWith('/studio/dashboard');
});
});

View File

@@ -0,0 +1,49 @@
import { get } from './request';
export interface IngestionRunFile {
documentId: string;
attachmentId?: string | null;
fileName?: string | null;
parseStatus?: string | null;
indexStatus?: string | null;
errorMessage?: string | null;
}
export interface IngestionRunStep {
name: string;
description?: string | null;
status?: string | null;
}
export interface IngestionRunLog {
time?: string | null;
level?: string | null;
message?: string | null;
}
export interface IngestionRun {
runId: string;
storeId: string;
documentId: string;
storeCode?: string | null;
storeName?: string | null;
files: IngestionRunFile[];
steps: IngestionRunStep[];
parsedTextPreview?: string | null;
chunkPreview?: string | null;
chunkStrategy?: number | null;
chunkSize?: number | null;
chunkOverlap?: number | null;
embeddingModelId?: string | null;
embeddingDimension?: number | null;
logs: IngestionRunLog[];
}
export function getIngestionRun(runId: string, storeId: string, documentId: string) {
return get<IngestionRun>(`/knowledge/ingestion-runs/${runId}`, {
params: {
storeId,
documentId,
},
});
}

View File

@@ -0,0 +1,44 @@
import { get } from './request';
export interface StudioDashboardLifecycleStep {
name: string;
description?: string | null;
status?: string | null;
}
export interface StudioDashboardChecklistItem {
label: string;
done: boolean;
}
export interface StudioDashboardMetrics {
todayRunCount: number;
successRate: number;
p50Latency?: string | null;
estimatedCost?: string | null;
}
export interface StudioDashboardRecentRun {
id: string;
name?: string | null;
type?: string | null;
status?: string | null;
latency?: string | null;
cost?: string | null;
}
export interface StudioDashboard {
projectName?: string | null;
environment?: string | null;
publishStatus?: string | null;
lifecycleSteps: StudioDashboardLifecycleStep[];
readinessChecklist: StudioDashboardChecklistItem[];
metrics: StudioDashboardMetrics;
recentRuns: StudioDashboardRecentRun[];
warningTitle?: string | null;
warningMessage?: string | null;
}
export function getStudioDashboard() {
return get<StudioDashboard>('/studio/dashboard');
}

View File

@@ -1,7 +1,72 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ChatDotRound, Coin, Timer } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { chatMessages, citations, traceSteps } from '@/data/studioMock';
import type { AgentMessageRecord, AgentWorkspace } from '@/api/agent';
import { appendAgentMessage, getAgentWorkspace } from '@/api/agent';
const loading = ref(false);
const agentId = ref('1001');
const workspace = ref<AgentWorkspace | null>(null);
const draftMessage = ref('');
const citations = computed(() => {
return (workspace.value?.messages ?? [])
.filter((message) => message.citationJson?.includes('chunkId'))
.map((message, index) => ({
key: `${message.id || index}`,
title: `引用 ${index + 1}`,
text: message.citationJson ?? '',
}));
});
const latestAssistantMessage = computed(() => {
const assistantMessages = (workspace.value?.messages ?? []).filter((item) => item.role === 'assistant');
return assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : null;
});
function roleLabel(role: AgentMessageRecord['role']) {
if (role === 'user') {
return '用户';
}
if (role === 'assistant') {
return 'Agent';
}
return '系统';
}
async function loadWorkspace() {
loading.value = true;
try {
const response = await getAgentWorkspace(agentId.value);
workspace.value = response.data;
} finally {
loading.value = false;
}
}
async function sendMessage() {
if (!workspace.value?.sessionId || !draftMessage.value.trim()) {
return;
}
await appendAgentMessage(workspace.value.sessionId, {
role: 'user',
content: draftMessage.value.trim(),
});
ElMessage.success('调试消息已写入会话');
draftMessage.value = '';
await loadWorkspace();
}
function formatCost(tokens?: number) {
if (!tokens) {
return '¥0.000';
}
return `¥${(tokens / 100000).toFixed(3)}`;
}
onMounted(loadWorkspace);
</script>
<template>
@@ -11,38 +76,47 @@ import { chatMessages, citations, traceSteps } from '@/data/studioMock';
<p class="studio-kicker">AgentWorkspaceView</p>
<h1>Agent 对话调试</h1>
</div>
<el-button type="primary">发布 Agent</el-button>
<el-button type="primary">{{ workspace?.status || 'Draft' }}</el-button>
</header>
<div class="agent-layout">
<div class="agent-layout" v-loading="loading">
<section class="studio-panel chat-panel">
<div class="panel-heading">
<div>
<h2>售前问答 Agent</h2>
<span>POST /api/agents/1001/runs</span>
<h2>{{ workspace?.agentName || 'Agent 工作台' }}</h2>
<span>GET /api/agent-sessions/workspace</span>
</div>
<el-tag>Draft</el-tag>
<el-tag>{{ workspace?.sessionStatus || workspace?.status || 'DRAFT' }}</el-tag>
</div>
<div class="message-list">
<article v-for="message in chatMessages" :key="message.content" :class="message.role">
<strong>{{ message.role === 'user' ? '用户' : 'Agent' }}</strong>
<article
v-for="message in workspace?.messages ?? []"
:key="message.id || message.content"
:class="message.role"
:data-test="`agent-message-${message.role}`"
>
<strong>{{ roleLabel(message.role) }}</strong>
<p>{{ message.content }}</p>
</article>
</div>
<div class="chat-composer">
<span>输入调试问题运行会写入 agent_session / agent_message 草案</span>
<el-button type="primary"><el-icon><ChatDotRound /></el-icon> 发送</el-button>
<span>输入调试问题写入当前 agent_session / agent_message</span>
<input v-model="draftMessage" data-test="agent-message-input" type="text" />
<el-button data-test="agent-send-message" type="primary" @click="sendMessage">
<el-icon><ChatDotRound /></el-icon>
发送
</el-button>
</div>
</section>
<aside class="studio-panel citation-panel">
<div class="panel-heading compact">
<h2>引用切片</h2>
<span>3 个来源</span>
<span>{{ workspace?.citationCount || 0 }} 个来源</span>
</div>
<article v-for="citation in citations" :key="citation.title" class="citation-card">
<article v-for="citation in citations" :key="citation.key" class="citation-card" data-test="agent-citation-card">
<strong>{{ citation.title }}</strong>
<el-tag type="success">score {{ citation.score }}</el-tag>
<el-tag type="success">引用</el-tag>
<p>{{ citation.text }}</p>
</article>
</aside>
@@ -50,17 +124,17 @@ import { chatMessages, citations, traceSteps } from '@/data/studioMock';
<aside class="studio-panel run-inspector">
<div class="panel-heading compact">
<h2>运行追踪</h2>
<span>modelRequestId: f4215d</span>
<span data-test="agent-latest-request-id">requestId: {{ workspace?.latestRequestId || '-' }}</span>
</div>
<div class="metric-mini">
<span><el-icon><Timer /></el-icon> 1.42s</span>
<span><el-icon><Coin /></el-icon> ¥0.018</span>
<span>1,248 tokens</span>
<span><el-icon><Timer /></el-icon> {{ workspace?.sessionStatus || '-' }}</span>
<span><el-icon><Coin /></el-icon> {{ formatCost(workspace?.totalTokens) }}</span>
<span>{{ workspace?.totalTokens || 0 }} tokens</span>
</div>
<ol class="log-list">
<li v-for="step in traceSteps" :key="step.node">
<time>{{ step.duration }}</time>
<span>{{ step.node }} · {{ step.output }}</span>
<li v-if="latestAssistantMessage">
<time>{{ workspace?.sessionCode || '-' }}</time>
<span>{{ latestAssistantMessage.content }}</span>
</li>
</ol>
</aside>

View File

@@ -1,29 +1,71 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { UploadFilled } from '@element-plus/icons-vue';
import { ingestionSteps } from '@/data/studioMock';
import type { IngestionRun } from '@/api/ingestion';
import { getIngestionRun } from '@/api/ingestion';
const loading = ref(false);
const runId = ref('run-20260601');
const storeId = ref('1001');
const documentId = ref('11');
const ingestionRun = ref<IngestionRun | null>(null);
const primaryFile = computed(() => ingestionRun.value?.files?.[0] ?? null);
function stepClass(status?: string | null) {
if (status === 'done') {
return 'is-done';
}
if (status === 'running') {
return 'is-running';
}
if (status === 'blocked') {
return 'is-blocked';
}
return 'is-idle';
}
async function loadRun() {
loading.value = true;
try {
const response = await getIngestionRun(runId.value, storeId.value, documentId.value);
ingestionRun.value = response.data;
} finally {
loading.value = false;
}
}
onMounted(loadRun);
</script>
<template>
<section class="studio-page ingestion-page">
<header class="page-title-row">
<div>
<p class="studio-kicker">IngestionPipelineView</p>
<p class="studio-kicker">IngestionRunView</p>
<h1>文件解析管道</h1>
</div>
<el-button type="primary">启动索引任务</el-button>
</header>
<div class="ingestion-layout">
<div class="ingestion-layout" v-loading="loading">
<section class="studio-panel upload-panel">
<div class="upload-dropzone">
<el-icon><UploadFilled /></el-icon>
<strong>拖拽文件到这里</strong>
<span>支持 PDF / Word / Excel / Markdown / TXT上传后自动创建 ingestion run</span>
<strong>{{ primaryFile?.fileName || '拖拽文件到这里' }}</strong>
<span>
支持 PDF / Word / Excel / Markdown / TXT上传后自动创建 ingestion run
</span>
<el-button type="primary">选择文件</el-button>
</div>
<div class="pipeline-timeline">
<article v-for="step in ingestionSteps" :key="step.name" :class="`is-${step.status}`">
<article
v-for="step in ingestionRun?.steps ?? []"
:key="step.name"
:class="stepClass(step.status)"
:data-test="`ingestion-step-${step.name}`"
>
<div class="timeline-dot" />
<div class="timeline-content">
<strong>{{ step.name }}</strong>
@@ -36,36 +78,40 @@ import { ingestionSteps } from '@/data/studioMock';
<section class="studio-panel preview-panel">
<div class="panel-heading">
<h2>解析与切片预览</h2>
<span>GET /api/knowledge/ingestion-runs/run-20260531</span>
<span>GET /api/knowledge/ingestion-runs/{{ ingestionRun?.runId || runId }}</span>
</div>
<div class="preview-split">
<article>
<h3>解析文本</h3>
<p>私有化部署章节应覆盖基础设施网络安全与运维边界平台需说明模型服务商知识库索引策略与日志留存周期...</p>
<p data-test="ingestion-parsed-preview">{{ ingestionRun?.parsedTextPreview || '暂无解析文本预览' }}</p>
</article>
<article>
<h3>切片 #24</h3>
<p>chunk_size=800, overlap=120, strategy=FIXED_LENGTH该切片将进入 rag_chunk 并在向量化后写入 rag_chunk_embedding</p>
<h3>切片预览</h3>
<p data-test="ingestion-chunk-preview">{{ ingestionRun?.chunkPreview || '暂无切片预览' }}</p>
</article>
</div>
<div class="pipeline-controls">
<label>切片策略 <strong>固定长度</strong></label>
<label>Chunk Size <strong>800</strong></label>
<label>Overlap <strong>120</strong></label>
<label>Embedding <strong>Qwen3 1024d</strong></label>
<label>切片策略 <strong>{{ ingestionRun?.chunkStrategy ?? '-' }}</strong></label>
<label>Chunk Size <strong>{{ ingestionRun?.chunkSize ?? '-' }}</strong></label>
<label>Overlap <strong>{{ ingestionRun?.chunkOverlap ?? '-' }}</strong></label>
<label>Embedding <strong>{{ ingestionRun?.embeddingModelId || '-' }} / {{ ingestionRun?.embeddingDimension || '-' }} </strong></label>
</div>
</section>
<aside class="studio-panel task-log-panel">
<div class="panel-heading compact">
<h2>任务日志</h2>
<span>run-20260531</span>
<span>{{ ingestionRun?.runId || runId }}</span>
</div>
<ol class="log-list">
<li><time>23:08:12</time><span>上传 4 个文件并创建 rag_document</span></li>
<li><time>23:08:24</time><span>Tika 解析完成 3 个文件</span></li>
<li class="warn"><time>23:08:31</time><span>服务条款更新.md 编码检测失败等待重试</span></li>
<li><time>23:08:40</time><span>切片任务进行中 68 / 119</span></li>
<li
v-for="item in ingestionRun?.logs ?? []"
:key="`${item.time}-${item.message}`"
:class="{ warn: item.level === 'WARN' }"
:data-test="`ingestion-log-${item.time}`"
>
<time>{{ item.time }}</time><span>{{ item.message }}</span>
</li>
</ol>
</aside>
</div>

View File

@@ -1,7 +1,35 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { DataAnalysis, Document, Setting } from '@element-plus/icons-vue';
import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
import type { KnowledgeWorkspace } from '@/api/knowledgeWorkspace';
import { getKnowledgeWorkspace } from '@/api/knowledgeWorkspace';
const loading = ref(false);
const activeStoreId = ref('1001');
const workspace = ref<KnowledgeWorkspace | null>(null);
const stores = computed(() => (workspace.value ? [workspace.value] : []));
function statusLabel(status?: string | null) {
if (status === 'ENABLED' || status === '可检索') {
return '可检索';
}
return status || '-';
}
async function loadWorkspace(storeId = activeStoreId.value) {
loading.value = true;
try {
const response = await getKnowledgeWorkspace(storeId);
workspace.value = response.data;
activeStoreId.value = response.data.storeId;
} finally {
loading.value = false;
}
}
onMounted(() => loadWorkspace());
</script>
<template>
@@ -14,61 +42,63 @@ import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
<el-button type="primary">新建知识库</el-button>
</header>
<div class="three-column-layout">
<div class="three-column-layout" v-loading="loading">
<aside class="studio-panel collection-rail">
<div class="panel-heading compact">
<h2>知识集合</h2>
<span>{{ knowledgeStores.length }} 个库</span>
<span>{{ stores.length }} 个库</span>
</div>
<button
v-for="store in knowledgeStores"
:key="store.id"
v-for="store in stores"
:key="store.storeId"
class="collection-item"
:class="{ active: store.id === '1001' }"
:class="{ active: String(store.storeId) === String(activeStoreId) }"
:data-test="`knowledge-store-${store.storeId}`"
@click="loadWorkspace(String(store.storeId))"
>
<strong>{{ store.name }}</strong>
<span>{{ store.docs }} 文档 · 健康度 {{ store.health }}%</span>
<em>{{ store.status }}</em>
<strong>{{ store.storeName }}</strong>
<span>{{ store.documentCount }} 文档 · 健康度 {{ store.healthScore }}%</span>
<em>{{ statusLabel(store.status) }}</em>
</button>
</aside>
<main class="studio-panel knowledge-main">
<div class="panel-heading">
<div>
<h2>产品制度库</h2>
<h2>{{ workspace?.storeName || '知识工作台' }}</h2>
<span>绑定旧数据语义rag_store / rag_document / rag_chunk_embedding</span>
</div>
<el-tag type="success">可检索</el-tag>
<el-tag type="success">{{ statusLabel(workspace?.status) }}</el-tag>
</div>
<div class="config-grid">
<article>
<el-icon><Setting /></el-icon>
<strong>Embedding 模型</strong>
<span>Qwen3-Embedding · 1024 </span>
<span>{{ workspace?.embeddingModelId || '-' }} · {{ workspace?.embeddingDimension || '-' }} </span>
</article>
<article>
<el-icon><DataAnalysis /></el-icon>
<strong>检索配置</strong>
<span>TopK 6 · Score 0.72 · Rerank 关闭</span>
<span>切片策略 {{ workspace?.chunkStrategy || '-' }} · Chunk {{ workspace?.chunkSize || '-' }}</span>
</article>
<article>
<el-icon><Document /></el-icon>
<strong>索引版本</strong>
<span>index_version 14 · Draft 快照</span>
<span>index_version {{ workspace?.indexVersion || '-' }}</span>
</article>
</div>
<div class="document-table">
<div class="table-row table-head">
<span>文档</span><span>解析</span><span>索引</span><span>切片</span><span>更新</span>
<span>文档</span><span>解析</span><span>索引</span><span>启用</span><span>更新</span>
</div>
<div v-for="doc in knowledgeDocuments" :key="doc.id" class="table-row">
<strong>{{ doc.name }}</strong>
<div v-for="doc in workspace?.documents ?? []" :key="doc.documentId" class="table-row" :data-test="`knowledge-document-${doc.documentId}`">
<strong>{{ doc.documentTitle }}</strong>
<span>{{ doc.parseStatus }}</span>
<span>{{ doc.indexStatus }}</span>
<span>{{ doc.chunks }}</span>
<span>{{ doc.updatedAt }}</span>
<span>{{ doc.enabled ? '是' : '否' }}</span>
<span>{{ doc.updateTime || '-' }}</span>
</div>
</div>
</main>
@@ -80,13 +110,13 @@ import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
</div>
<dl class="inspector-list">
<dt>Workspace API</dt>
<dd>GET /api/knowledge/workspaces/1001</dd>
<dd>GET /api/knowledge/workspaces/{{ workspace?.storeId || '-' }}</dd>
<dt>文档健康度</dt>
<dd>96% · 1 个解析失败</dd>
<dd>{{ workspace?.healthScore || 0 }}% · {{ workspace?.parseFailedDocumentCount || 0 }} 个解析失败</dd>
<dt>待处理任务</dt>
<dd>2 个文档等待向量化</dd>
<dd>{{ workspace?.pendingTaskCount || 0 }} 个文档等待处理</dd>
<dt>发布影响</dt>
<dd>更新后需要 Workflow 重新验证引用质量</dd>
<dd>{{ workspace?.publishImpact || '-' }}</dd>
</dl>
</aside>
</div>

View File

@@ -1,5 +1,27 @@
<script setup lang="ts">
import { modelRoutes } from '@/data/studioMock';
import { onMounted, ref } from 'vue';
import type { ModelWorkspace } from '@/api/modelWorkspace';
import { getModelWorkspace } from '@/api/modelWorkspace';
const loading = ref(false);
const workspace = ref<ModelWorkspace | null>(null);
function routeStatus(enabled?: boolean) {
return enabled ? '启用' : '草稿';
}
async function loadWorkspace() {
loading.value = true;
try {
const response = await getModelWorkspace();
workspace.value = response.data;
} finally {
loading.value = false;
}
}
onMounted(loadWorkspace);
</script>
<template>
@@ -12,23 +34,28 @@ import { modelRoutes } from '@/data/studioMock';
<el-button type="primary">新增路由</el-button>
</header>
<div class="studio-panel model-panel">
<div class="studio-panel model-panel" v-loading="loading">
<div class="panel-heading">
<h2>任务路由规则</h2>
<span>保留 model_provider / model_config / model_route_rule 语义</span>
</div>
<div class="metric-row">
<div><strong>{{ workspace?.providerCount || 0 }}</strong><span>服务商</span></div>
<div><strong>{{ workspace?.modelCount || 0 }}</strong><span>模型</span></div>
<div><strong>{{ workspace?.routeRuleCount || 0 }}</strong><span>路由规则</span></div>
<div><strong>{{ workspace?.recentFailedCallCount || 0 }}</strong><span>最近失败</span></div>
</div>
<div class="document-table">
<div class="table-row table-head">
<span>任务</span><span>主模型</span><span>Fallback</span><span>最大延迟</span><span>状态</span>
<span>任务</span><span>主模型</span><span>Fallback</span><span>状态</span>
</div>
<div v-for="route in modelRoutes" :key="route.task" class="table-row">
<strong>{{ route.task }}</strong>
<span>{{ route.primary }}</span>
<span>{{ route.fallback }}</span>
<span>{{ route.latency }}</span>
<div v-for="route in workspace?.routes ?? []" :key="route.id || route.taskType" class="table-row" :data-test="`model-route-${route.taskType}`">
<strong>{{ route.taskType }}</strong>
<span>{{ route.primaryModelCode || route.primaryModelId }}</span>
<span>{{ route.fallbackModelCode || route.fallbackModelId || '无' }}</span>
<span class="status-cell">
<span class="status-pill" :class="route.status === '启用' ? 'is-success' : 'is-warning'">
{{ route.status }}
<span class="status-pill" :class="routeStatus(route.enabled) === '启用' ? 'is-success' : 'is-warning'">
{{ routeStatus(route.enabled) }}
</span>
</span>
</div>

View File

@@ -1,14 +1,43 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ArrowRight, Check, Warning } from '@element-plus/icons-vue';
import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMock';
import type { StudioDashboard } from '@/api/studioDashboard';
import { getStudioDashboard } from '@/api/studioDashboard';
const loading = ref(false);
const dashboard = ref<StudioDashboard | null>(null);
const metrics = computed(() => dashboard.value?.metrics);
function formatPublishStatus(status?: string | null) {
if (status === 'PUBLISHED') {
return 'Published';
}
if (status === 'DRAFT') {
return 'Draft';
}
return status || 'Draft';
}
async function loadDashboard() {
loading.value = true;
try {
const response = await getStudioDashboard();
dashboard.value = response.data;
} finally {
loading.value = false;
}
}
onMounted(loadDashboard);
</script>
<template>
<section class="studio-page dashboard-page">
<section class="studio-page dashboard-page" v-loading="loading">
<header class="studio-hero">
<div>
<p class="studio-kicker">项目 / Common Agent Studio</p>
<p class="studio-kicker">项目 / {{ dashboard?.projectName || 'Common Agent Studio' }}</p>
<h1>从知识接入到 Agent 发布的一体化工作台</h1>
<p>
使用新的聚合 ViewModel 驱动原型知识资产WorkflowMCPSkillAgent 调试与观测都围绕一次发布旅程组织
@@ -21,13 +50,19 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
</header>
<div class="lifecycle-strip">
<article v-for="(step, index) in lifecycleSteps" :key="step.name" class="lifecycle-step" :class="`is-${step.status}`">
<article
v-for="(step, index) in dashboard?.lifecycleSteps ?? []"
:key="step.name"
class="lifecycle-step"
:class="`is-${step.status}`"
:data-test="`dashboard-lifecycle-${step.name}`"
>
<div class="step-index">{{ index + 1 }}</div>
<div>
<strong>{{ step.name }}</strong>
<span>{{ step.description }}</span>
</div>
<el-icon v-if="index < lifecycleSteps.length - 1"><ArrowRight /></el-icon>
<el-icon v-if="dashboard?.lifecycleSteps && index < dashboard.lifecycleSteps.length - 1"><ArrowRight /></el-icon>
</article>
</div>
@@ -38,10 +73,15 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
<h2>发布就绪检查</h2>
<span>ViewModel: StudioDashboardView</span>
</div>
<el-tag type="warning">Draft</el-tag>
<el-tag type="warning">{{ formatPublishStatus(dashboard?.publishStatus) }}</el-tag>
</div>
<ul class="check-list">
<li v-for="item in readinessChecklist" :key="item.label" :class="{ done: item.done }">
<li
v-for="item in dashboard?.readinessChecklist ?? []"
:key="item.label"
:class="{ done: item.done }"
:data-test="`dashboard-check-${item.label}`"
>
<el-icon>
<Check v-if="item.done" />
<span v-else class="pending-dot" />
@@ -54,13 +94,13 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
<section class="studio-panel metrics-panel">
<div class="panel-heading">
<h2>运行概览</h2>
<span>环境: Dev</span>
<span>环境: {{ dashboard?.environment || 'Dev' }}</span>
</div>
<div class="metric-row">
<div><strong>27</strong><span>今日运行</span></div>
<div><strong>96.4%</strong><span>成功率</span></div>
<div><strong>1.28s</strong><span>P50 延迟</span></div>
<div><strong>¥4.82</strong><span>预估成本</span></div>
<div><strong>{{ metrics?.todayRunCount ?? 0 }}</strong><span>今日运行</span></div>
<div><strong>{{ metrics?.successRate ?? 0 }}%</strong><span>成功率</span></div>
<div><strong>{{ metrics?.p50Latency || '-' }}</strong><span>P50 延迟</span></div>
<div><strong>{{ metrics?.estimatedCost || '-' }}</strong><span>预估成本</span></div>
</div>
</section>
@@ -73,7 +113,12 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
<div class="run-row run-head">
<span>名称</span><span>类型</span><span>状态</span><span>延迟</span><span>成本</span>
</div>
<div v-for="run in recentRuns" :key="run.id" class="run-row">
<div
v-for="run in dashboard?.recentRuns ?? []"
:key="run.id"
class="run-row"
:data-test="`dashboard-run-${run.id}`"
>
<strong>{{ run.name }}</strong>
<span>{{ run.type }}</span>
<span class="status-cell">
@@ -90,8 +135,8 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
<section class="studio-panel warning-panel">
<el-icon><Warning /></el-icon>
<div>
<h2>生产发布前仍需确认路由兜底</h2>
<p>AGENT_PLAN 任务当前只有草稿路由建议补齐 fallback 模型和最大延迟阈值</p>
<h2>{{ dashboard?.warningTitle || '生产发布前仍需确认路由兜底' }}</h2>
<p>{{ dashboard?.warningMessage || 'AGENT_PLAN 任务当前只有草稿路由建议补齐 fallback 模型和最大延迟阈值。' }}</p>
</div>
</section>
</div>

View File

@@ -1,27 +1,83 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Connection, Cpu, VideoPlay } from '@element-plus/icons-vue';
import { traceSteps, workflowEdges, workflowNodes } from '@/data/studioMock';
import type { WorkflowWorkspace } from '@/api/workflow';
import { getWorkflowWorkspace } from '@/api/workflow';
const nodeById = Object.fromEntries(workflowNodes.map((node) => [node.id, node]));
const canvasEdges = workflowEdges.flatMap((edge) => {
const from = nodeById[edge.from];
const to = nodeById[edge.to];
const loading = ref(false);
const projectId = ref('101');
const workflowId = ref('201');
const workspace = ref<WorkflowWorkspace | null>(null);
if (!from || !to) {
const nodeLibrary = ['Start', 'LLM', 'Knowledge Retrieval', 'MCP Tool', 'Skill', 'Condition', 'Answer'];
const workflowNodes = computed(() => {
const graphJson = workspace.value?.versions?.[0]?.graphJson;
if (!graphJson) {
return [];
}
try {
const parsed = JSON.parse(graphJson) as { nodes?: Array<Record<string, unknown>> };
return (parsed.nodes ?? []).map((node, index) => ({
id: String(node.id ?? `node-${index}`),
type: String(node.type ?? 'NODE'),
label: String(node.label ?? node.id ?? `Node ${index + 1}`),
description: String(node.description ?? node.prompt ?? ''),
x: Number(node.x ?? 10 + index * 18),
y: Number(node.y ?? 42),
}));
} catch {
return [];
}
});
return [
{
id: `${edge.from}-${edge.to}`,
const workflowEdges = computed(() => {
const graphJson = workspace.value?.versions?.[0]?.graphJson;
if (!graphJson) {
return [];
}
try {
const parsed = JSON.parse(graphJson) as { edges?: Array<Record<string, unknown>> };
return (parsed.edges ?? []).map((edge, index) => ({
id: `edge-${index}`,
from: String(edge.from ?? edge.source ?? ''),
to: String(edge.to ?? edge.target ?? ''),
}));
} catch {
return [];
}
});
const canvasEdges = computed(() => {
const nodeById = Object.fromEntries(workflowNodes.value.map((node) => [node.id, node]));
return workflowEdges.value.flatMap((edge) => {
const from = nodeById[edge.from];
const to = nodeById[edge.to];
if (!from || !to) {
return [];
}
return [{
id: edge.id,
x1: from.x + 5,
y1: from.y + 4,
x2: to.x,
y2: to.y + 4,
},
];
}];
});
});
async function loadWorkspace() {
loading.value = true;
try {
const response = await getWorkflowWorkspace(projectId.value, workflowId.value);
workspace.value = response.data;
} finally {
loading.value = false;
}
}
onMounted(loadWorkspace);
</script>
<template>
@@ -37,26 +93,20 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
</div>
</header>
<div class="workflow-layout">
<div class="workflow-layout" v-loading="loading">
<aside class="studio-panel node-library">
<div class="panel-heading compact">
<h2>节点库</h2>
<span>JSON Graph</span>
</div>
<button>Start</button>
<button>LLM</button>
<button>Knowledge Retrieval</button>
<button>MCP Tool</button>
<button>Skill</button>
<button>Condition</button>
<button>Answer</button>
<button v-for="node in nodeLibrary" :key="node">{{ node }}</button>
</aside>
<main class="studio-panel workflow-canvas">
<div class="canvas-toolbar">
<span><el-icon><Connection /></el-icon> workflow-support-rag</span>
<span>版本快照 v7</span>
<span>环境: Dev</span>
<span><el-icon><Connection /></el-icon> {{ workspace?.workflowCode || 'workflow' }}</span>
<span>版本快照 v{{ workspace?.currentPublishedVersionNo || workspace?.versions?.[0]?.versionNo || '-' }}</span>
<span>环境: {{ workspace?.environment || 'Dev' }}</span>
</div>
<div class="canvas-surface">
<svg class="edge-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
@@ -73,8 +123,9 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
v-for="node in workflowNodes"
:key="node.id"
class="workflow-node"
:class="{ selected: node.id === 'llm' }"
:class="{ selected: node.type === 'LLM' }"
:style="{ left: `${node.x}%`, top: `${node.y}%` }"
:data-test="`workflow-node-${node.id}`"
>
<span>{{ node.type }}</span>
<strong>{{ node.label }}</strong>
@@ -83,9 +134,9 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
</div>
<div class="run-trace-drawer">
<strong>Run Trace</strong>
<div v-for="step in traceSteps" :key="step.node">
<span>{{ step.node }}</span>
<em>{{ step.status }} · {{ step.duration }} · {{ step.output }}</em>
<div v-for="run in workspace?.recentRuns ?? []" :key="run.requestId" :data-test="`workflow-run-${run.requestId}`">
<span>{{ run.requestId }}</span>
<em>{{ run.status }} · {{ run.durationMs || 0 }}ms · {{ run.outputJson || '-' }}</em>
</div>
</div>
</main>
@@ -93,17 +144,17 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
<aside class="studio-panel inspector-panel">
<div class="panel-heading compact">
<h2>节点 Inspector</h2>
<span>LLM</span>
<span>{{ workflowNodes[0]?.type || 'LLM' }}</span>
</div>
<dl class="inspector-list">
<dt>任务类型</dt>
<dd>RAG_ANSWER</dd>
<dt>输入 Schema</dt>
<dd>question, retrieved_chunks, conversation</dd>
<dt>输出 Schema</dt>
<dd>answer, citations, safety_flags</dd>
<dt>路由策略</dt>
<dd>primary qwen-plus / fallback deepseek-v3</dd>
<dt>工作流</dt>
<dd>{{ workspace?.workflowName || '-' }}</dd>
<dt>发布状态</dt>
<dd>{{ workspace?.publishStatus || '-' }}</dd>
<dt>当前版本</dt>
<dd>v{{ workspace?.currentPublishedVersionNo || workspace?.versions?.[0]?.versionNo || '-' }}</dd>
<dt>最近请求</dt>
<dd>{{ workspace?.latestRequestId || '-' }}</dd>
</dl>
<button class="blue-command"><el-icon><Cpu /></el-icon> 打开模型路由</button>
</aside>

View File

@@ -0,0 +1,94 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentWorkspacePage from '../AgentWorkspacePage.vue';
import { appendAgentMessage, getAgentWorkspace } from '@/api/agent';
vi.mock('@/api/agent', () => ({
getAgentWorkspace: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
agentId: '1001',
agentCode: 'sales_agent',
agentName: '售前问答 Agent',
storeId: '2001',
status: 'DRAFT',
sessionId: '3001',
sessionCode: 'session-001',
sessionStatus: 'ACTIVE',
totalTokens: 1248,
citationCount: 1,
latestRequestId: 'req-1001',
sessions: [],
messages: [
{
id: '1',
sessionId: '3001',
role: 'user',
content: '如果客户要求私有化部署,需要说明哪些内容?',
},
{
id: '2',
sessionId: '3001',
role: 'assistant',
content: '建议说明部署拓扑、权限边界和日志留存策略。',
citationJson: '[{"chunkId":"c-1"}]',
tokenCount: 612,
},
],
},
}),
),
appendAgentMessage: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
listAgents: vi.fn(),
queryAgents: vi.fn(),
getAgentById: vi.fn(),
saveAgent: vi.fn(),
deleteAgent: vi.fn(),
chatWithAgent: vi.fn(),
createAgentSession: vi.fn(),
getAgentSessionById: vi.fn(),
listAgentMessages: vi.fn(),
}));
describe('AgentWorkspacePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads workspace messages and citations from backend api', async () => {
const wrapper = mount(AgentWorkspacePage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getAgentWorkspace).toHaveBeenCalledWith('1001');
expect(wrapper.text()).toContain('售前问答 Agent');
expect(wrapper.text()).toContain('建议说明部署拓扑');
expect(wrapper.findAll('[data-test="agent-citation-card"]').length).toBe(1);
});
it('appends debug message through backend api', async () => {
const wrapper = mount(AgentWorkspacePage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
await wrapper.get('[data-test="agent-message-input"]').setValue('补充说明日志留存周期');
await wrapper.get('[data-test="agent-send-message"]').trigger('click');
await flushPromises();
expect(appendAgentMessage).toHaveBeenCalledWith('3001', {
role: 'user',
content: '补充说明日志留存周期',
});
});
});

View File

@@ -0,0 +1,66 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import IngestionPipelinePage from '../IngestionPipelinePage.vue';
import { getIngestionRun } from '@/api/ingestion';
vi.mock('@/api/ingestion', () => ({
getIngestionRun: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
runId: 'run-20260601',
storeId: '1001',
documentId: '11',
storeName: '产品制度库',
files: [
{
documentId: '11',
fileName: '售前方案模板.pdf',
parseStatus: 'PARSED',
indexStatus: 'INDEXED',
},
],
steps: [
{ name: '上传', description: '文件已入库并创建 rag_document', status: 'done' },
{ name: '解析', description: '解析完成,文本长度 1280页数 12', status: 'done' },
{ name: '切片', description: '已生成 24 个切片chunk_size=800overlap=120', status: 'running' },
],
parsedTextPreview: '私有化部署章节应覆盖基础设施、网络、安全与运维边界。',
chunkPreview: 'chunk_size=800, overlap=120, strategy=1。预览该切片将进入 rag_chunk 并在向量化后写入 rag_chunk_embedding。',
chunkStrategy: 1,
chunkSize: 800,
chunkOverlap: 120,
embeddingModelId: '88',
embeddingDimension: 1024,
logs: [
{ time: '23:08:12', level: 'INFO', message: '上传文件并创建 rag_document 记录' },
{ time: '23:08:24', level: 'INFO', message: '解析完成,文本长度 1280页数 12' },
],
},
}),
),
}));
describe('IngestionPipelinePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads ingestion aggregate from backend api', async () => {
const wrapper = mount(IngestionPipelinePage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getIngestionRun).toHaveBeenCalledWith('run-20260601', '1001', '11');
expect(wrapper.text()).toContain('售前方案模板.pdf');
expect(wrapper.text()).toContain('私有化部署章节应覆盖基础设施');
expect(wrapper.text()).toContain('上传文件并创建 rag_document 记录');
});
});

View File

@@ -0,0 +1,64 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import KnowledgeWorkspacePage from '../KnowledgeWorkspacePage.vue';
import { getKnowledgeWorkspace } from '@/api/knowledgeWorkspace';
vi.mock('@/api/knowledgeWorkspace', () => ({
getKnowledgeWorkspace: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
storeId: '1001',
storeCode: 'PROD_DOC',
storeName: '产品制度库',
status: 'ENABLED',
documentCount: 9,
parsedDocumentCount: 6,
parseFailedDocumentCount: 1,
indexedDocumentCount: 5,
pendingTaskCount: 2,
healthScore: 96,
embeddingModelId: '88',
embeddingDimension: 1024,
chunkStrategy: 1,
chunkSize: 800,
indexVersion: 14,
publishImpact: '更新后需要 Workflow 重新验证引用质量',
documents: [
{
documentId: '11',
documentTitle: '售前方案模板.pdf',
parseStatus: 'PARSED',
indexStatus: 'INDEXED',
enabled: true,
updateTime: '2026-06-01 10:00:00',
},
],
},
}),
),
}));
describe('KnowledgeWorkspacePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads knowledge workspace aggregate from backend api', async () => {
const wrapper = mount(KnowledgeWorkspacePage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getKnowledgeWorkspace).toHaveBeenCalledWith('1001');
expect(wrapper.text()).toContain('产品制度库');
expect(wrapper.text()).toContain('96%');
expect(wrapper.find('[data-test="knowledge-document-11"]').text()).toContain('售前方案模板.pdf');
});
});

View File

@@ -0,0 +1,58 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ModelWorkspacePage from '../ModelWorkspacePage.vue';
import { getModelWorkspace } from '@/api/modelWorkspace';
vi.mock('@/api/modelWorkspace', () => ({
getModelWorkspace: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
providerCount: 3,
healthyProviderCount: 2,
unhealthyProviderCount: 1,
modelCount: 5,
enabledModelCount: 4,
routeRuleCount: 2,
enabledRouteRuleCount: 1,
recentFailedCallCount: 2,
providers: [],
models: [],
routes: [
{
id: '11',
taskType: 'RAG_ANSWER',
primaryModelCode: 'qwen-plus',
fallbackModelCode: 'deepseek-v3',
enabled: true,
},
],
failedCallSummaries: [],
},
}),
),
}));
describe('ModelWorkspacePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads model workspace aggregate from backend api', async () => {
const wrapper = mount(ModelWorkspacePage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getModelWorkspace).toHaveBeenCalled();
expect(wrapper.text()).toContain('3');
expect(wrapper.find('[data-test="model-route-RAG_ANSWER"]').text()).toContain('qwen-plus');
expect(wrapper.find('[data-test="model-route-RAG_ANSWER"]').text()).toContain('deepseek-v3');
});
});

View File

@@ -0,0 +1,61 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import StudioDashboardPage from '../StudioDashboardPage.vue';
import { getStudioDashboard } from '@/api/studioDashboard';
vi.mock('@/api/studioDashboard', () => ({
getStudioDashboard: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
projectName: 'Common Agent Studio',
environment: 'Dev',
publishStatus: 'DRAFT',
lifecycleSteps: [
{ name: '知识接入', description: '上传、解析、切片、向量化', status: 'done' },
{ name: '能力编排', description: 'Workflow 连接模型、工具与 Skill', status: 'running' },
],
readinessChecklist: [
{ label: '知识库已绑定 Embedding 模型', done: true },
{ label: 'Workflow 已存在可编辑草稿', done: false },
],
metrics: {
todayRunCount: 27,
successRate: 96.4,
p50Latency: '1.28s',
estimatedCost: '¥4.82',
},
recentRuns: [
{ id: 'req-1001', name: '售前问答 Agent', type: 'Agent', status: '成功', latency: '1.42s', cost: '¥0.018' },
],
warningTitle: '生产发布前仍需确认路由兜底',
warningMessage: 'AGENT_PLAN 任务建议补齐 fallback 模型和最大延迟阈值后再发布。',
},
}),
),
}));
describe('StudioDashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads dashboard aggregate from backend api', async () => {
const wrapper = mount(StudioDashboardPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getStudioDashboard).toHaveBeenCalled();
expect(wrapper.text()).toContain('Common Agent Studio');
expect(wrapper.text()).toContain('96.4%');
expect(wrapper.text()).toContain('售前问答 Agent');
expect(wrapper.text()).toContain('AGENT_PLAN 任务建议补齐 fallback 模型');
});
});

View File

@@ -0,0 +1,89 @@
import { flushPromises, mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import WorkflowBuilderPage from '../WorkflowBuilderPage.vue';
import { getWorkflowWorkspace } from '@/api/workflow';
vi.mock('@/api/workflow', () => ({
getWorkflowWorkspace: vi.fn(() =>
Promise.resolve({
resultcode: '0',
message: null,
data: {
projectId: '101',
projectCode: 'studio_demo',
projectName: '演示项目',
environment: 'DEV',
publishStatus: 'PUBLISHED',
workflowId: '201',
workflowCode: 'workflow-support-rag',
workflowName: '知识问答流程',
workflowStatus: 'ENABLED',
currentPublishedVersionNo: 7,
latestRequestId: 'req-2001',
latestDurationMs: 860,
workflows: [],
versions: [
{
id: '301',
workflowId: '201',
versionNo: 7,
snapshotName: '知识问答发布版',
graphJson:
'{"nodes":[{"id":"start","type":"START","label":"Start","x":4,"y":42},{"id":"llm","type":"LLM","label":"LLM","x":47,"y":42}],"edges":[{"from":"start","to":"llm"}]}',
publishStatus: 'PUBLISHED',
},
],
recentRuns: [
{
id: '401',
workflowId: '201',
workflowVersionId: '301',
requestId: 'req-2001',
status: 'SUCCESS',
durationMs: 860,
outputJson: '{"answer":"done"}',
},
],
},
}),
),
listWorkflowDefinitions: vi.fn(),
getWorkflowDefinition: vi.fn(),
saveWorkflowDefinition: vi.fn(),
}));
describe('WorkflowBuilderPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('loads workflow workspace and renders graph nodes from backend api', async () => {
const wrapper = mount(WorkflowBuilderPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(getWorkflowWorkspace).toHaveBeenCalledWith('101', '201');
expect(wrapper.text()).toContain('workflow-support-rag');
expect(wrapper.find('[data-test="workflow-node-start"]').text()).toContain('Start');
expect(wrapper.find('[data-test="workflow-node-llm"]').text()).toContain('LLM');
});
it('renders recent run trace from workspace aggregate', async () => {
const wrapper = mount(WorkflowBuilderPage, {
global: {
plugins: [ElementPlus],
},
});
await flushPromises();
expect(wrapper.find('[data-test="workflow-run-req-2001"]').text()).toContain('SUCCESS');
expect(wrapper.find('[data-test="workflow-run-req-2001"]').text()).toContain('860ms');
});
});