mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(wiki): user-defined transformation templates with optional auto-save to synthesis pages
This commit is contained in:
parent
5f77434953
commit
bae9f68261
@ -0,0 +1,221 @@
|
|||||||
|
package vip.mate.wiki.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationRunEntity;
|
||||||
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
|
import vip.mate.wiki.service.WikiTransformationExecutor;
|
||||||
|
import vip.mate.wiki.service.WikiTransformationService;
|
||||||
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Management surface for user-defined wiki transformation templates and
|
||||||
|
* their execution history. Templates live under the workspace; a template
|
||||||
|
* with non-null {@code kbId} is pinned to a single KB, otherwise it is
|
||||||
|
* available to every KB in the workspace.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Wiki Transformations",
|
||||||
|
description = "User-defined prompt templates run over wiki raw materials")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/wiki/transformations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WikiTransformationController {
|
||||||
|
|
||||||
|
private final WikiTransformationService transformationService;
|
||||||
|
private final WikiTransformationExecutor executor;
|
||||||
|
private final WikiKnowledgeBaseService kbService;
|
||||||
|
|
||||||
|
// ==================== Templates ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@Operation(summary = "List transformations available to a KB",
|
||||||
|
description = "Returns templates pinned to the KB plus workspace-wide templates.")
|
||||||
|
@GetMapping
|
||||||
|
public R<List<WikiTransformationEntity>> list(
|
||||||
|
@RequestParam(required = false) Long kbId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
if (kbId != null) {
|
||||||
|
verifyKBWorkspace(kbId, wsId);
|
||||||
|
return R.ok(transformationService.listForKb(kbId, wsId));
|
||||||
|
}
|
||||||
|
return R.ok(transformationService.listByWorkspace(wsId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public R<WikiTransformationEntity> get(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationEntity t = transformationService.getById(id);
|
||||||
|
if (t == null) return R.fail("Transformation not found");
|
||||||
|
verifyTemplateWorkspace(t, workspaceId);
|
||||||
|
return R.ok(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@PostMapping
|
||||||
|
public R<WikiTransformationEntity> create(@RequestBody WikiTransformationEntity body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
if (body.getKbId() != null) {
|
||||||
|
verifyKBWorkspace(body.getKbId(), wsId);
|
||||||
|
}
|
||||||
|
body.setWorkspaceId(wsId);
|
||||||
|
WikiTransformationEntity created = transformationService.create(body);
|
||||||
|
return R.ok(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public R<WikiTransformationEntity> update(@PathVariable Long id,
|
||||||
|
@RequestBody WikiTransformationEntity body,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationEntity existing = transformationService.getById(id);
|
||||||
|
if (existing == null) return R.fail("Transformation not found");
|
||||||
|
verifyTemplateWorkspace(existing, workspaceId);
|
||||||
|
return R.ok(transformationService.update(id, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public R<Void> delete(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationEntity existing = transformationService.getById(id);
|
||||||
|
if (existing != null) {
|
||||||
|
verifyTemplateWorkspace(existing, workspaceId);
|
||||||
|
transformationService.delete(id);
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Apply ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@Operation(summary = "Run a transformation against a raw material",
|
||||||
|
description = "Set sync=true to block until the LLM call returns "
|
||||||
|
+ "(the response carries the populated run). "
|
||||||
|
+ "When false (default) the call returns immediately with the pending run row.")
|
||||||
|
@PostMapping("/{id}/apply")
|
||||||
|
public R<WikiTransformationRunEntity> apply(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
@RequestParam(defaultValue = "false") boolean sync,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationEntity t = transformationService.getById(id);
|
||||||
|
if (t == null) return R.fail("Transformation not found");
|
||||||
|
verifyTemplateWorkspace(t, workspaceId);
|
||||||
|
|
||||||
|
Object rawIdRaw = body == null ? null : body.get("rawId");
|
||||||
|
if (rawIdRaw == null) {
|
||||||
|
return R.fail("rawId is required");
|
||||||
|
}
|
||||||
|
Long rawId = Long.valueOf(rawIdRaw.toString());
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
return R.ok(executor.runOnRawSync(t, rawId, "manual"));
|
||||||
|
}
|
||||||
|
executor.runOnRawAsync(t, rawId, "manual");
|
||||||
|
// Async path — caller polls /runs to see the result land.
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Runs ====================
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@GetMapping("/runs/{runId}")
|
||||||
|
public R<WikiTransformationRunEntity> getRun(@PathVariable Long runId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||||
|
if (run == null) return R.fail("Run not found");
|
||||||
|
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||||
|
return R.ok(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("viewer")
|
||||||
|
@GetMapping("/runs")
|
||||||
|
public R<List<WikiTransformationRunEntity>> listRuns(
|
||||||
|
@RequestParam(required = false) Long rawId,
|
||||||
|
@RequestParam(required = false) Long kbId,
|
||||||
|
@RequestParam(required = false) Long transformationId,
|
||||||
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
if (rawId != null) {
|
||||||
|
return R.ok(transformationService.listRunsByRaw(rawId, limit));
|
||||||
|
}
|
||||||
|
if (transformationId != null) {
|
||||||
|
WikiTransformationEntity t = transformationService.getById(transformationId);
|
||||||
|
if (t == null) return R.fail("Transformation not found");
|
||||||
|
verifyTemplateWorkspace(t, wsId);
|
||||||
|
return R.ok(transformationService.listRunsByTransformation(transformationId, limit));
|
||||||
|
}
|
||||||
|
if (kbId != null) {
|
||||||
|
verifyKBWorkspace(kbId, wsId);
|
||||||
|
return R.ok(transformationService.listRunsByKb(kbId, limit));
|
||||||
|
}
|
||||||
|
return R.fail("One of rawId / kbId / transformationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@Operation(summary = "Save a completed run's output as a synthesis wiki page",
|
||||||
|
description = "Idempotent: re-saving an already-saved run updates the same page slug.")
|
||||||
|
@PostMapping("/runs/{runId}/save-as-page")
|
||||||
|
public R<Map<String, Object>> saveRunAsPage(@PathVariable Long runId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||||
|
if (run == null) return R.fail("Run not found");
|
||||||
|
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||||
|
try {
|
||||||
|
var page = executor.manualSaveRunAsPage(runId);
|
||||||
|
if (page == null) return R.fail("Page service unavailable");
|
||||||
|
return R.ok(Map.of(
|
||||||
|
"pageId", page.getId(),
|
||||||
|
"slug", page.getSlug(),
|
||||||
|
"title", page.getTitle()));
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
return R.fail(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireWorkspaceRole("member")
|
||||||
|
@DeleteMapping("/runs/{runId}")
|
||||||
|
public R<Void> deleteRun(@PathVariable Long runId,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||||
|
if (run != null) {
|
||||||
|
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||||
|
transformationService.deleteRun(runId);
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helpers ====================
|
||||||
|
|
||||||
|
private void verifyKBWorkspace(Long kbId, Long workspaceId) {
|
||||||
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
|
if (kb == null) {
|
||||||
|
throw new MateClawException("Knowledge base not found");
|
||||||
|
}
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
|
||||||
|
throw new MateClawException("err.common.wrong_workspace", "Resource does not belong to current workspace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyTemplateWorkspace(WikiTransformationEntity t, Long workspaceId) {
|
||||||
|
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||||
|
if (t.getWorkspaceId() != null && !t.getWorkspaceId().equals(wsId)) {
|
||||||
|
throw new MateClawException("err.common.wrong_workspace", "Resource does not belong to current workspace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package vip.mate.wiki.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-defined prompt template applied to a knowledge base's raw materials
|
||||||
|
* (and, eventually, pages). One template + one source = one
|
||||||
|
* {@link WikiTransformationRunEntity}.
|
||||||
|
*
|
||||||
|
* <p>Template body supports the placeholders {@code {input_text}} and
|
||||||
|
* {@code {title}}, replaced by the executor before the LLM call.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_wiki_transformation")
|
||||||
|
public class WikiTransformationEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinned KB. {@code null} means the template is available to every KB
|
||||||
|
* in the workspace.
|
||||||
|
*/
|
||||||
|
private Long kbId;
|
||||||
|
|
||||||
|
private Long workspaceId;
|
||||||
|
|
||||||
|
/** Stable short identifier; unique per {@code kbId}. */
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** Prompt body with {@code {input_text}} / {@code {title}} placeholders. */
|
||||||
|
private String promptTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, the ingestion pipeline fires this template automatically
|
||||||
|
* for every raw material that lands in {@code completed} for a matching
|
||||||
|
* KB.
|
||||||
|
*/
|
||||||
|
private Boolean applyDefault;
|
||||||
|
|
||||||
|
/** Optional explicit model override; {@code null} = use KB default. */
|
||||||
|
private Long modelId;
|
||||||
|
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the output of a successful run lands.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code none} — output stays in the run history only (default).</li>
|
||||||
|
* <li>{@code page} — output is upserted as a synthesis wiki page on the
|
||||||
|
* same KB; subsequent runs against the same source raw material
|
||||||
|
* update the same page rather than spawning duplicates.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private String outputTarget;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package vip.mate.wiki.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One execution of a {@link WikiTransformationEntity} against a source
|
||||||
|
* (raw material today; pages in a follow-up). Output is stored inline so
|
||||||
|
* the UI can render the result without re-running the LLM.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_wiki_transformation_run")
|
||||||
|
public class WikiTransformationRunEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long transformationId;
|
||||||
|
private Long kbId;
|
||||||
|
private Long workspaceId;
|
||||||
|
|
||||||
|
/** {@code raw} | {@code page} | {@code text}. */
|
||||||
|
private String inputKind;
|
||||||
|
|
||||||
|
private Long rawId;
|
||||||
|
private Long pageId;
|
||||||
|
|
||||||
|
/** {@code pending} | {@code running} | {@code completed} | {@code failed}. */
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** LLM output; treat as Markdown unless the prompt asked for JSON. */
|
||||||
|
private String output;
|
||||||
|
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
/** Model that actually produced the output after routing fallback. */
|
||||||
|
private Long modelId;
|
||||||
|
|
||||||
|
/** {@code apply_default} | {@code manual} | {@code agent_tool}. */
|
||||||
|
private String triggeredBy;
|
||||||
|
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
private Long durationMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when the run was persisted as a synthesis wiki page (either
|
||||||
|
* automatically because the template's {@code outputTarget} is {@code page},
|
||||||
|
* or manually via the save-as-page endpoint). Points at
|
||||||
|
* {@code mate_wiki_page.id}.
|
||||||
|
*/
|
||||||
|
private Long outputPageId;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package vip.mate.wiki.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationEntity;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WikiTransformationMapper extends BaseMapper<WikiTransformationEntity> {
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package vip.mate.wiki.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationRunEntity;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WikiTransformationRunMapper extends BaseMapper<WikiTransformationRunEntity> {
|
||||||
|
}
|
||||||
@ -114,6 +114,15 @@ public class WikiProcessingService {
|
|||||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
private WikiLogService logService;
|
private WikiLogService logService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. When present, every successful ingest triggers an async sweep
|
||||||
|
* of the KB's apply-default transformation templates. Missing in the
|
||||||
|
* legacy unit tests that wire this service directly.
|
||||||
|
*/
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
@org.springframework.context.annotation.Lazy
|
||||||
|
private WikiTransformationExecutor transformationExecutor;
|
||||||
|
|
||||||
/** Parallel chunk / material processing executor (JDK 21 virtual threads) */
|
/** Parallel chunk / material processing executor (JDK 21 virtual threads) */
|
||||||
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
public static final ExecutorService WIKI_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
@ -412,6 +421,14 @@ public class WikiProcessingService {
|
|||||||
eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId()));
|
eventPublisher.publishEvent(new vip.mate.wiki.event.WikiKbDirtyEvent(this, kb.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run apply-default transformation templates against the newly
|
||||||
|
// ingested raw material. Fire-and-forget; failures are logged
|
||||||
|
// inside the executor and do not affect the ingest outcome.
|
||||||
|
if (transformationExecutor != null && nonTerminalSideEffects) {
|
||||||
|
Long wsId = kb.getWorkspaceId() == null ? 1L : kb.getWorkspaceId();
|
||||||
|
transformationExecutor.runDefaultsAsync(kb.getId(), wsId, rawId, "apply_default");
|
||||||
|
}
|
||||||
|
|
||||||
log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}",
|
log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}",
|
||||||
rawId, kb.getId(), totalPages, pageCount);
|
rawId, kb.getId(), totalPages, pageCount);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,323 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
|
import vip.mate.wiki.job.WikiJobStep;
|
||||||
|
import vip.mate.wiki.job.WikiModelRoutingService;
|
||||||
|
import vip.mate.wiki.metrics.WikiMetrics;
|
||||||
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationRunEntity;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a single {@link WikiTransformationEntity} against a source raw
|
||||||
|
* material: substitutes placeholders into the user-defined prompt, calls
|
||||||
|
* the configured chat model, and persists the run row with the output.
|
||||||
|
*
|
||||||
|
* <p>Sync entry point: {@link #runOnRawSync}. Async fire-and-forget
|
||||||
|
* helpers (used by the ingest-pipeline hook and the controller's "apply"
|
||||||
|
* endpoint when the caller doesn't want to block) wrap that on a virtual
|
||||||
|
* thread executor.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WikiTransformationExecutor {
|
||||||
|
|
||||||
|
/** Virtual-thread pool — matches the WIKI_EXECUTOR pattern used elsewhere in the module. */
|
||||||
|
private static final ExecutorService WORKER = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
|
/** Hard cap on input text fed into the prompt (defensive against multi-MB extracted PDFs). */
|
||||||
|
private static final int MAX_INPUT_CHARS = 60_000;
|
||||||
|
|
||||||
|
private final WikiTransformationService transformationService;
|
||||||
|
private final WikiRawMaterialService rawService;
|
||||||
|
private final WikiMetrics metrics;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private WikiModelRoutingService modelRoutingService;
|
||||||
|
|
||||||
|
/** Optional. When wired, completed runs whose template has
|
||||||
|
* {@code outputTarget=page} are persisted as a synthesis wiki page. */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private WikiPageService pageService;
|
||||||
|
|
||||||
|
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper =
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
|
||||||
|
public CompletableFuture<Void> runDefaultsAsync(Long kbId, Long workspaceId, Long rawId, String triggeredBy) {
|
||||||
|
return CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
List<WikiTransformationEntity> defaults =
|
||||||
|
transformationService.listApplyDefaultsForKb(kbId, workspaceId);
|
||||||
|
for (WikiTransformationEntity t : defaults) {
|
||||||
|
try {
|
||||||
|
runOnRawSync(t, rawId, triggeredBy);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[WikiTransformation] default run failed transformation={} rawId={}: {}",
|
||||||
|
t.getName(), rawId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[WikiTransformation] default sweep failed kbId={} rawId={}: {}",
|
||||||
|
kbId, rawId, e.getMessage());
|
||||||
|
}
|
||||||
|
}, WORKER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<WikiTransformationRunEntity> runOnRawAsync(
|
||||||
|
WikiTransformationEntity transformation, Long rawId, String triggeredBy) {
|
||||||
|
return CompletableFuture.supplyAsync(
|
||||||
|
() -> runOnRawSync(transformation, rawId, triggeredBy), WORKER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the transformation against the given raw material and persist
|
||||||
|
* the outcome. The returned entity is the persisted run row, regardless
|
||||||
|
* of success or failure (failure leaves {@code status=failed} and
|
||||||
|
* {@code error} populated).
|
||||||
|
*/
|
||||||
|
public WikiTransformationRunEntity runOnRawSync(
|
||||||
|
WikiTransformationEntity transformation, Long rawId, String triggeredBy) {
|
||||||
|
if (transformation == null) {
|
||||||
|
throw new IllegalArgumentException("transformation is required");
|
||||||
|
}
|
||||||
|
if (rawId == null) {
|
||||||
|
throw new IllegalArgumentException("rawId is required");
|
||||||
|
}
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
||||||
|
if (raw == null) {
|
||||||
|
throw new IllegalArgumentException("Raw material not found: " + rawId);
|
||||||
|
}
|
||||||
|
if (Boolean.FALSE.equals(transformation.getEnabled())) {
|
||||||
|
log.debug("[WikiTransformation] skipping disabled template id={} name={}",
|
||||||
|
transformation.getId(), transformation.getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long startNanos = System.nanoTime();
|
||||||
|
WikiTransformationRunEntity run = new WikiTransformationRunEntity();
|
||||||
|
run.setTransformationId(transformation.getId());
|
||||||
|
run.setKbId(raw.getKbId());
|
||||||
|
run.setWorkspaceId(transformation.getWorkspaceId());
|
||||||
|
run.setInputKind("raw");
|
||||||
|
run.setRawId(rawId);
|
||||||
|
run.setStatus("running");
|
||||||
|
run.setTriggeredBy(triggeredBy == null ? "manual" : triggeredBy);
|
||||||
|
run.setStartedAt(LocalDateTime.now());
|
||||||
|
transformationService.insertRun(run);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String inputText = rawService.getTextContent(raw);
|
||||||
|
if (inputText == null || inputText.isBlank()) {
|
||||||
|
throw new IllegalStateException("Raw material has no extractable text yet");
|
||||||
|
}
|
||||||
|
String trimmedInput = inputText.length() > MAX_INPUT_CHARS
|
||||||
|
? inputText.substring(0, MAX_INPUT_CHARS) + "\n…(truncated)"
|
||||||
|
: inputText;
|
||||||
|
|
||||||
|
String systemPrompt = PromptLoader.loadPrompt("wiki/transformation-system");
|
||||||
|
String userPrompt = PromptLoader.loadPrompt("wiki/transformation-user")
|
||||||
|
.replace("{instruction}", renderTemplate(transformation.getPromptTemplate(), raw, trimmedInput))
|
||||||
|
.replace("{source_title}", safeTitle(raw))
|
||||||
|
.replace("{source_text}", trimmedInput);
|
||||||
|
|
||||||
|
Long resolvedModelId = resolveModelId(transformation, raw.getKbId());
|
||||||
|
ChatModel chatModel = buildChatModel(resolvedModelId);
|
||||||
|
run.setModelId(resolvedModelId);
|
||||||
|
|
||||||
|
ChatResponse resp = chatModel.call(new Prompt(List.of(
|
||||||
|
new SystemMessage(systemPrompt), new UserMessage(userPrompt))));
|
||||||
|
String output = (resp == null || resp.getResult() == null
|
||||||
|
|| resp.getResult().getOutput() == null)
|
||||||
|
? null : resp.getResult().getOutput().getText();
|
||||||
|
if (output == null || output.isBlank()) {
|
||||||
|
throw new IllegalStateException("LLM returned empty output");
|
||||||
|
}
|
||||||
|
run.setOutput(output);
|
||||||
|
run.setStatus("completed");
|
||||||
|
run.setCompletedAt(LocalDateTime.now());
|
||||||
|
run.setDurationMs(Duration.ofNanos(System.nanoTime() - startNanos).toMillis());
|
||||||
|
|
||||||
|
// Persist as a synthesis wiki page when the template asks for it.
|
||||||
|
// Failures here are logged but do not flip the run back to failed:
|
||||||
|
// the LLM output is already valid, the page-write is best-effort.
|
||||||
|
if ("page".equalsIgnoreCase(transformation.getOutputTarget())) {
|
||||||
|
try {
|
||||||
|
WikiPageEntity page = saveRunAsPage(run, transformation, raw, output);
|
||||||
|
if (page != null) run.setOutputPageId(page.getId());
|
||||||
|
} catch (Exception pe) {
|
||||||
|
log.warn("[WikiTransformation] auto-save as page failed run={}: {}",
|
||||||
|
run.getId(), pe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transformationService.updateRun(run);
|
||||||
|
|
||||||
|
metrics.recordCompileStage("transformation_run", raw.getKbId(),
|
||||||
|
Duration.ofNanos(System.nanoTime() - startNanos));
|
||||||
|
log.info("[WikiTransformation] ok run={} transformation={} rawId={} kbId={} ({} ms, pageId={})",
|
||||||
|
run.getId(), transformation.getName(), rawId, raw.getKbId(),
|
||||||
|
run.getDurationMs(), run.getOutputPageId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
run.setStatus("failed");
|
||||||
|
String msg = e.getMessage();
|
||||||
|
run.setError(msg == null ? e.getClass().getSimpleName() : msg);
|
||||||
|
run.setCompletedAt(LocalDateTime.now());
|
||||||
|
run.setDurationMs(Duration.ofNanos(System.nanoTime() - startNanos).toMillis());
|
||||||
|
transformationService.updateRun(run);
|
||||||
|
log.warn("[WikiTransformation] failed run={} transformation={} rawId={}: {}",
|
||||||
|
run.getId(), transformation.getName(), rawId, msg);
|
||||||
|
}
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String renderTemplate(String template, WikiRawMaterialEntity raw, String inputText) {
|
||||||
|
if (template == null) return "";
|
||||||
|
String result = template;
|
||||||
|
result = result.replace("{input_text}", inputText);
|
||||||
|
result = result.replace("{title}", safeTitle(raw));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveModelId(WikiTransformationEntity transformation, Long kbId) {
|
||||||
|
if (transformation.getModelId() != null) return transformation.getModelId();
|
||||||
|
if (modelRoutingService == null) {
|
||||||
|
throw new IllegalStateException("No model bound on transformation and ModelRoutingService unavailable");
|
||||||
|
}
|
||||||
|
return modelRoutingService.selectModelId(kbId, "heavy_ingest", WikiJobStep.CREATE_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatModel buildChatModel(Long modelId) {
|
||||||
|
if (modelRoutingService == null) {
|
||||||
|
throw new IllegalStateException("ModelRoutingService unavailable; cannot run transformation");
|
||||||
|
}
|
||||||
|
return modelRoutingService.buildChatModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeTitle(WikiRawMaterialEntity raw) {
|
||||||
|
String t = raw.getTitle();
|
||||||
|
return (t == null || t.isBlank()) ? ("raw#" + raw.getId()) : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Save-as-page ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual entry point used by the {@code POST /runs/{runId}/save-as-page}
|
||||||
|
* endpoint. Loads the run + its template + its source raw material,
|
||||||
|
* delegates to {@link #saveRunAsPage}, and updates the run row with the
|
||||||
|
* resulting page id so the UI can render a "saved as: …" affordance.
|
||||||
|
*
|
||||||
|
* @return the persisted page; never {@code null} on success
|
||||||
|
* @throws IllegalArgumentException when the run / raw / template is missing
|
||||||
|
* @throws IllegalStateException when the run is not completed or no output
|
||||||
|
*/
|
||||||
|
public WikiPageEntity manualSaveRunAsPage(Long runId) {
|
||||||
|
if (runId == null) throw new IllegalArgumentException("runId is required");
|
||||||
|
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||||
|
if (run == null) throw new IllegalArgumentException("Run not found: " + runId);
|
||||||
|
if (!"completed".equalsIgnoreCase(run.getStatus())) {
|
||||||
|
throw new IllegalStateException("Run is not completed (status=" + run.getStatus() + ")");
|
||||||
|
}
|
||||||
|
if (run.getOutput() == null || run.getOutput().isBlank()) {
|
||||||
|
throw new IllegalStateException("Run has no output to save");
|
||||||
|
}
|
||||||
|
if (run.getRawId() == null) {
|
||||||
|
throw new IllegalStateException("Run is not bound to a raw material");
|
||||||
|
}
|
||||||
|
WikiTransformationEntity template = transformationService.getById(run.getTransformationId());
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalStateException("Transformation template no longer exists");
|
||||||
|
}
|
||||||
|
WikiRawMaterialEntity raw = rawService.getById(run.getRawId());
|
||||||
|
if (raw == null) {
|
||||||
|
throw new IllegalStateException("Source raw material no longer exists");
|
||||||
|
}
|
||||||
|
WikiPageEntity page = saveRunAsPage(run, template, raw, run.getOutput());
|
||||||
|
if (page != null) {
|
||||||
|
run.setOutputPageId(page.getId());
|
||||||
|
transformationService.updateRun(run);
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert the transformation output as a synthesis wiki page on the same
|
||||||
|
* KB. Slug is deterministic — {@code <template.name>-<raw.title-slug-or-id>} —
|
||||||
|
* so re-running an apply_default template against the same raw material
|
||||||
|
* updates the existing page in place rather than spawning duplicates.
|
||||||
|
*/
|
||||||
|
private WikiPageEntity saveRunAsPage(WikiTransformationRunEntity run,
|
||||||
|
WikiTransformationEntity template,
|
||||||
|
WikiRawMaterialEntity raw,
|
||||||
|
String output) {
|
||||||
|
if (pageService == null) {
|
||||||
|
log.warn("[WikiTransformation] save-as-page requested but WikiPageService not available");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Long kbId = raw.getKbId();
|
||||||
|
String slug = buildSlug(template, raw);
|
||||||
|
String title = template.getTitle() + " · " + safeTitle(raw);
|
||||||
|
String summary = deriveSummary(output);
|
||||||
|
String sourceRawIdsJson = toJsonArray(raw.getId());
|
||||||
|
|
||||||
|
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
|
||||||
|
WikiPageEntity persisted;
|
||||||
|
if (existing == null) {
|
||||||
|
persisted = pageService.createPage(kbId, slug, title, output, summary,
|
||||||
|
sourceRawIdsJson, "synthesis");
|
||||||
|
log.info("[WikiTransformation] saved run={} as new page slug={} pageId={}",
|
||||||
|
run.getId(), slug, persisted.getId());
|
||||||
|
} else {
|
||||||
|
persisted = pageService.updatePageByAi(kbId, slug, output, summary, raw.getId());
|
||||||
|
if (persisted == null) persisted = existing;
|
||||||
|
log.info("[WikiTransformation] updated existing synthesis page slug={} pageId={} from run={}",
|
||||||
|
slug, persisted.getId(), run.getId());
|
||||||
|
}
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildSlug(WikiTransformationEntity template, WikiRawMaterialEntity raw) {
|
||||||
|
String rawPart = WikiPageService.toSlug(raw.getTitle());
|
||||||
|
if (rawPart == null || rawPart.isBlank()) rawPart = "r" + raw.getId();
|
||||||
|
return template.getName() + "-" + rawPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First non-empty line of the output, capped to ~280 chars, used as page summary. */
|
||||||
|
private static String deriveSummary(String output) {
|
||||||
|
if (output == null) return "";
|
||||||
|
for (String line : output.split("\\n")) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty()) continue;
|
||||||
|
if (trimmed.startsWith("#")) {
|
||||||
|
trimmed = trimmed.replaceAll("^#+\\s*", "");
|
||||||
|
if (trimmed.isEmpty()) continue;
|
||||||
|
}
|
||||||
|
return trimmed.length() > 280 ? trimmed.substring(0, 280) + "…" : trimmed;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJsonArray(Long rawId) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(java.util.List.of(rawId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "[" + rawId + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationRunEntity;
|
||||||
|
import vip.mate.wiki.repository.WikiTransformationMapper;
|
||||||
|
import vip.mate.wiki.repository.WikiTransformationRunMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD + lookups for wiki transformation templates and their execution
|
||||||
|
* history. Pure persistence — the LLM call lives in
|
||||||
|
* {@link WikiTransformationExecutor}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WikiTransformationService {
|
||||||
|
|
||||||
|
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$");
|
||||||
|
|
||||||
|
private final WikiTransformationMapper transformationMapper;
|
||||||
|
private final WikiTransformationRunMapper runMapper;
|
||||||
|
|
||||||
|
/** Templates visible to a KB: pinned to this KB plus workspace-wide ones (kb_id NULL). */
|
||||||
|
public List<WikiTransformationEntity> listForKb(Long kbId, Long workspaceId) {
|
||||||
|
if (kbId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return transformationMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationEntity>()
|
||||||
|
.and(w -> w.eq(WikiTransformationEntity::getKbId, kbId)
|
||||||
|
.or(g -> g.isNull(WikiTransformationEntity::getKbId)
|
||||||
|
.eq(WikiTransformationEntity::getWorkspaceId, workspaceId)))
|
||||||
|
.orderByDesc(WikiTransformationEntity::getUpdateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WikiTransformationEntity> listByWorkspace(Long workspaceId) {
|
||||||
|
return transformationMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationEntity>()
|
||||||
|
.eq(WikiTransformationEntity::getWorkspaceId, workspaceId)
|
||||||
|
.orderByDesc(WikiTransformationEntity::getUpdateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
public WikiTransformationEntity getById(Long id) {
|
||||||
|
return transformationMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<WikiTransformationEntity> findByName(Long kbId, Long workspaceId, String name) {
|
||||||
|
if (name == null || name.isBlank()) return Optional.empty();
|
||||||
|
// Prefer the KB-pinned record over a workspace-wide one of the same name.
|
||||||
|
WikiTransformationEntity pinned = transformationMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationEntity>()
|
||||||
|
.eq(WikiTransformationEntity::getKbId, kbId)
|
||||||
|
.eq(WikiTransformationEntity::getName, name)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (pinned != null) return Optional.of(pinned);
|
||||||
|
WikiTransformationEntity global = transformationMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationEntity>()
|
||||||
|
.isNull(WikiTransformationEntity::getKbId)
|
||||||
|
.eq(WikiTransformationEntity::getWorkspaceId, workspaceId)
|
||||||
|
.eq(WikiTransformationEntity::getName, name)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
return Optional.ofNullable(global);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default-apply templates that should run for a raw material in {@code kbId}. */
|
||||||
|
public List<WikiTransformationEntity> listApplyDefaultsForKb(Long kbId, Long workspaceId) {
|
||||||
|
return listForKb(kbId, workspaceId).stream()
|
||||||
|
.filter(t -> Boolean.TRUE.equals(t.getApplyDefault()))
|
||||||
|
.filter(t -> !Boolean.FALSE.equals(t.getEnabled()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WikiTransformationEntity create(WikiTransformationEntity input) {
|
||||||
|
validateName(input.getName());
|
||||||
|
if (input.getTitle() == null || input.getTitle().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("title is required");
|
||||||
|
}
|
||||||
|
if (input.getPromptTemplate() == null || input.getPromptTemplate().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("promptTemplate is required");
|
||||||
|
}
|
||||||
|
Long workspaceId = input.getWorkspaceId() == null ? 1L : input.getWorkspaceId();
|
||||||
|
|
||||||
|
// Enforce uniqueness on (kbId, name) — including the NULL-kbId case
|
||||||
|
// where MySQL would otherwise allow duplicates.
|
||||||
|
findByExactScopeAndName(input.getKbId(), workspaceId, input.getName())
|
||||||
|
.ifPresent(existing -> {
|
||||||
|
throw new IllegalArgumentException("Transformation already exists: " + input.getName());
|
||||||
|
});
|
||||||
|
|
||||||
|
WikiTransformationEntity entity = new WikiTransformationEntity();
|
||||||
|
entity.setKbId(input.getKbId());
|
||||||
|
entity.setWorkspaceId(workspaceId);
|
||||||
|
entity.setName(input.getName());
|
||||||
|
entity.setTitle(input.getTitle());
|
||||||
|
entity.setDescription(input.getDescription());
|
||||||
|
entity.setPromptTemplate(input.getPromptTemplate());
|
||||||
|
entity.setApplyDefault(Boolean.TRUE.equals(input.getApplyDefault()));
|
||||||
|
entity.setEnabled(input.getEnabled() == null ? Boolean.TRUE : input.getEnabled());
|
||||||
|
entity.setModelId(input.getModelId());
|
||||||
|
entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget()));
|
||||||
|
transformationMapper.insert(entity);
|
||||||
|
log.info("[WikiTransformation] created id={} name={} kbId={}",
|
||||||
|
entity.getId(), entity.getName(), entity.getKbId());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WikiTransformationEntity update(Long id, WikiTransformationEntity patch) {
|
||||||
|
WikiTransformationEntity entity = transformationMapper.selectById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("Transformation not found: " + id);
|
||||||
|
}
|
||||||
|
if (patch.getTitle() != null) entity.setTitle(patch.getTitle());
|
||||||
|
if (patch.getDescription() != null) entity.setDescription(patch.getDescription());
|
||||||
|
if (patch.getPromptTemplate() != null) entity.setPromptTemplate(patch.getPromptTemplate());
|
||||||
|
if (patch.getApplyDefault() != null) entity.setApplyDefault(patch.getApplyDefault());
|
||||||
|
if (patch.getEnabled() != null) entity.setEnabled(patch.getEnabled());
|
||||||
|
// modelId is allowed to be cleared via explicit -1 sentinel handled by controller;
|
||||||
|
// here we only honour non-null assignments.
|
||||||
|
if (patch.getModelId() != null) {
|
||||||
|
entity.setModelId(patch.getModelId() < 0 ? null : patch.getModelId());
|
||||||
|
}
|
||||||
|
if (patch.getOutputTarget() != null) {
|
||||||
|
entity.setOutputTarget(normalizeOutputTarget(patch.getOutputTarget()));
|
||||||
|
}
|
||||||
|
transformationMapper.updateById(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitelist incoming outputTarget; unknown / null = "none". */
|
||||||
|
private static String normalizeOutputTarget(String raw) {
|
||||||
|
if (raw == null) return "none";
|
||||||
|
String trimmed = raw.trim().toLowerCase();
|
||||||
|
return switch (trimmed) {
|
||||||
|
case "page" -> "page";
|
||||||
|
default -> "none";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
transformationMapper.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Runs ====================
|
||||||
|
|
||||||
|
public WikiTransformationRunEntity getRun(Long runId) {
|
||||||
|
return runMapper.selectById(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WikiTransformationRunEntity> listRunsByRaw(Long rawId, int limit) {
|
||||||
|
return runMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationRunEntity>()
|
||||||
|
.eq(WikiTransformationRunEntity::getRawId, rawId)
|
||||||
|
.orderByDesc(WikiTransformationRunEntity::getCreateTime)
|
||||||
|
.last("LIMIT " + Math.max(1, Math.min(limit, 200))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WikiTransformationRunEntity> listRunsByKb(Long kbId, int limit) {
|
||||||
|
return runMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationRunEntity>()
|
||||||
|
.eq(WikiTransformationRunEntity::getKbId, kbId)
|
||||||
|
.orderByDesc(WikiTransformationRunEntity::getCreateTime)
|
||||||
|
.last("LIMIT " + Math.max(1, Math.min(limit, 200))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WikiTransformationRunEntity> listRunsByTransformation(Long transformationId, int limit) {
|
||||||
|
return runMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<WikiTransformationRunEntity>()
|
||||||
|
.eq(WikiTransformationRunEntity::getTransformationId, transformationId)
|
||||||
|
.orderByDesc(WikiTransformationRunEntity::getCreateTime)
|
||||||
|
.last("LIMIT " + Math.max(1, Math.min(limit, 200))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WikiTransformationRunEntity insertRun(WikiTransformationRunEntity run) {
|
||||||
|
runMapper.insert(run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateRun(WikiTransformationRunEntity run) {
|
||||||
|
runMapper.updateById(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteRun(Long runId) {
|
||||||
|
runMapper.deleteById(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== helpers ====================
|
||||||
|
|
||||||
|
private Optional<WikiTransformationEntity> findByExactScopeAndName(Long kbId, Long workspaceId, String name) {
|
||||||
|
LambdaQueryWrapper<WikiTransformationEntity> q = new LambdaQueryWrapper<>();
|
||||||
|
if (kbId == null) {
|
||||||
|
q.isNull(WikiTransformationEntity::getKbId)
|
||||||
|
.eq(WikiTransformationEntity::getWorkspaceId, workspaceId);
|
||||||
|
} else {
|
||||||
|
q.eq(WikiTransformationEntity::getKbId, kbId);
|
||||||
|
}
|
||||||
|
q.eq(WikiTransformationEntity::getName, name).last("LIMIT 1");
|
||||||
|
return Optional.ofNullable(transformationMapper.selectOne(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateName(String name) {
|
||||||
|
if (name == null || !NAME_PATTERN.matcher(name).matches()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"name must be 3-64 chars, lowercase letters / digits / hyphens (start and end alphanumeric)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
|||||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
import vip.mate.wiki.model.WikiPageEntity;
|
import vip.mate.wiki.model.WikiPageEntity;
|
||||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationEntity;
|
||||||
|
import vip.mate.wiki.model.WikiTransformationRunEntity;
|
||||||
import vip.mate.wiki.repository.WikiRawMaterialMapper;
|
import vip.mate.wiki.repository.WikiRawMaterialMapper;
|
||||||
import vip.mate.wiki.service.*;
|
import vip.mate.wiki.service.*;
|
||||||
|
|
||||||
@ -59,6 +61,13 @@ public class WikiTool {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private WikiCompileService compileService;
|
private WikiCompileService compileService;
|
||||||
|
|
||||||
|
/** Optional transformation engine. Tools degrade with a clear error when missing. */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private WikiTransformationService transformationService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private WikiTransformationExecutor transformationExecutor;
|
||||||
|
|
||||||
public WikiTool(WikiPageService pageService,
|
public WikiTool(WikiPageService pageService,
|
||||||
WikiKnowledgeBaseService kbService,
|
WikiKnowledgeBaseService kbService,
|
||||||
WikiRawMaterialService rawService,
|
WikiRawMaterialService rawService,
|
||||||
@ -632,6 +641,78 @@ public class WikiTool {
|
|||||||
return "Wikilink enrichment queued for: " + slug;
|
return "Wikilink enrichment queued for: " + slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Transformations ====================
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
List the transformation templates available to this agent's wiki KB.
|
||||||
|
Each result has a name (use it with wiki_apply_transformation), a
|
||||||
|
human title, and a description of what the prompt produces.
|
||||||
|
""")
|
||||||
|
public String wiki_list_transformations(
|
||||||
|
@ToolParam(description = "Agent ID") Long agentId) {
|
||||||
|
Long kbId = resolveKbId(agentId);
|
||||||
|
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||||
|
if (transformationService == null) return error("Transformations not available");
|
||||||
|
|
||||||
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
|
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
||||||
|
|
||||||
|
List<WikiTransformationEntity> templates = transformationService.listForKb(kbId, wsId);
|
||||||
|
JSONArray arr = new JSONArray();
|
||||||
|
for (WikiTransformationEntity t : templates) {
|
||||||
|
if (Boolean.FALSE.equals(t.getEnabled())) continue;
|
||||||
|
arr.add(JSONUtil.createObj()
|
||||||
|
.set("name", t.getName())
|
||||||
|
.set("title", t.getTitle())
|
||||||
|
.set("description", t.getDescription())
|
||||||
|
.set("applyDefault", Boolean.TRUE.equals(t.getApplyDefault())));
|
||||||
|
}
|
||||||
|
return JSONUtil.createObj().set("kbId", kbId).set("transformations", arr).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = """
|
||||||
|
Run a transformation template against one raw material and return the
|
||||||
|
generated text. Use wiki_list_transformations first to discover names.
|
||||||
|
The run is also persisted so the result is visible in the wiki UI.
|
||||||
|
""")
|
||||||
|
public String wiki_apply_transformation(
|
||||||
|
@ToolParam(description = "Agent ID") Long agentId,
|
||||||
|
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
|
||||||
|
@ToolParam(description = "Raw material ID to run the transformation against") Long rawId) {
|
||||||
|
if (name == null || name.isBlank()) return error("name is required");
|
||||||
|
if (rawId == null) return error("rawId is required");
|
||||||
|
Long kbId = resolveKbId(agentId);
|
||||||
|
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||||
|
if (transformationService == null || transformationExecutor == null) {
|
||||||
|
return error("Transformations not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
|
Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId();
|
||||||
|
|
||||||
|
WikiTransformationEntity template = transformationService.findByName(kbId, wsId, name).orElse(null);
|
||||||
|
if (template == null) return error("Transformation not found: " + name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
WikiTransformationRunEntity run = transformationExecutor.runOnRawSync(template, rawId, "agent_tool");
|
||||||
|
if (run == null) return error("Transformation is disabled: " + name);
|
||||||
|
if ("failed".equals(run.getStatus())) {
|
||||||
|
return error("Transformation failed: " + run.getError());
|
||||||
|
}
|
||||||
|
return JSONUtil.createObj()
|
||||||
|
.set("ok", true)
|
||||||
|
.set("runId", run.getId())
|
||||||
|
.set("transformation", template.getName())
|
||||||
|
.set("output", run.getOutput())
|
||||||
|
.toString();
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
return error(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[WikiTool] wiki_apply_transformation failed: {}", e.getMessage());
|
||||||
|
return error("Apply failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
private Long resolveKbId(Long agentId) {
|
private Long resolveKbId(Long agentId) {
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
-- Reusable user-defined prompt templates ("transformations") that run over
|
||||||
|
-- a raw material's extracted text and persist the LLM output as an artifact
|
||||||
|
-- on the knowledge base. Templates can be flagged apply_default so the
|
||||||
|
-- ingestion pipeline runs them automatically once a raw material reaches
|
||||||
|
-- the completed state. Manual / agent-tool runs are also supported.
|
||||||
|
--
|
||||||
|
-- mate_wiki_transformation — the template (prompt + metadata)
|
||||||
|
-- mate_wiki_transformation_run — one row per execution attempt
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_wiki_transformation (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- NULL = workspace-wide template available to every KB in the workspace.
|
||||||
|
-- Non-NULL = pinned to a single KB.
|
||||||
|
kb_id BIGINT NULL,
|
||||||
|
|
||||||
|
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Short stable identifier (e.g. "risk-extract"). Used by agent tools to
|
||||||
|
-- target a transformation without exposing numeric IDs.
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
|
-- Human-readable label shown in the UI.
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
description VARCHAR(1024),
|
||||||
|
|
||||||
|
-- Prompt body. Placeholders supported by the executor:
|
||||||
|
-- {input_text} — extracted text of the source raw material
|
||||||
|
-- {title} — title of the source raw material
|
||||||
|
prompt_template CLOB NOT NULL,
|
||||||
|
|
||||||
|
-- When true, the executor fires this transformation automatically for
|
||||||
|
-- every raw material that reaches completed in the matching KB.
|
||||||
|
apply_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Optional explicit model override. NULL = fall back to the KB-bound
|
||||||
|
-- chat model (same routing chain WikiCompileService uses).
|
||||||
|
model_id BIGINT NULL,
|
||||||
|
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wtr_kb ON mate_wiki_transformation (kb_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wtr_ws ON mate_wiki_transformation (workspace_id, deleted);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_wtr_kb_name ON mate_wiki_transformation (kb_id, name, deleted);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_wiki_transformation_run (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
transformation_id BIGINT NOT NULL,
|
||||||
|
kb_id BIGINT NOT NULL,
|
||||||
|
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Either raw_id or page_id is set; input_kind says which.
|
||||||
|
input_kind VARCHAR(16) NOT NULL,
|
||||||
|
raw_id BIGINT NULL,
|
||||||
|
page_id BIGINT NULL,
|
||||||
|
|
||||||
|
-- pending | running | completed | failed
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
-- LLM output. Treat as Markdown unless the prompt asked for JSON.
|
||||||
|
output CLOB,
|
||||||
|
|
||||||
|
error VARCHAR(2048),
|
||||||
|
|
||||||
|
-- Model that actually produced the output (after routing).
|
||||||
|
model_id BIGINT NULL,
|
||||||
|
|
||||||
|
-- apply_default | manual | agent_tool
|
||||||
|
triggered_by VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||||
|
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
duration_ms BIGINT NULL,
|
||||||
|
|
||||||
|
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wtrn_tr ON mate_wiki_transformation_run (transformation_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wtrn_kb ON mate_wiki_transformation_run (kb_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wtrn_raw ON mate_wiki_transformation_run (raw_id, deleted);
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- Two-part follow-up to V105 so a transformation's output can flow back
|
||||||
|
-- into the KB as a first-class artifact:
|
||||||
|
--
|
||||||
|
-- 1. mate_wiki_transformation.output_target — declarative target for the
|
||||||
|
-- template's output. `none` = legacy behaviour (output stays in the run
|
||||||
|
-- history only). `page` = after a successful run, persist the output as
|
||||||
|
-- a synthesis wiki page derived from the source raw material. Runs an
|
||||||
|
-- upsert against a deterministic slug so re-running is idempotent.
|
||||||
|
--
|
||||||
|
-- 2. mate_wiki_transformation_run.output_page_id — when a run was saved as
|
||||||
|
-- a page (either via apply_default=page or the manual save-as-page
|
||||||
|
-- endpoint), this points at mate_wiki_page.id so the UI can render a
|
||||||
|
-- "saved as: <slug>" link without a join through sourceRawIds.
|
||||||
|
|
||||||
|
ALTER TABLE mate_wiki_transformation
|
||||||
|
ADD COLUMN IF NOT EXISTS output_target VARCHAR(16) NOT NULL DEFAULT 'none';
|
||||||
|
|
||||||
|
ALTER TABLE mate_wiki_transformation_run
|
||||||
|
ADD COLUMN IF NOT EXISTS output_page_id BIGINT NULL;
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
-- Reusable user-defined prompt templates ("transformations") that run over
|
||||||
|
-- a raw material's extracted text and persist the LLM output as an artifact
|
||||||
|
-- on the knowledge base. Templates can be flagged apply_default so the
|
||||||
|
-- ingestion pipeline runs them automatically once a raw material reaches
|
||||||
|
-- the completed state. Manual / agent-tool runs are also supported.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_wiki_transformation (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
kb_id BIGINT NULL,
|
||||||
|
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description VARCHAR(1024),
|
||||||
|
|
||||||
|
prompt_template MEDIUMTEXT NOT NULL,
|
||||||
|
|
||||||
|
apply_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
model_id BIGINT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
KEY idx_wtr_kb (kb_id, deleted),
|
||||||
|
KEY idx_wtr_ws (workspace_id, deleted)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Unique name per KB (NULL kb_id rows compete in a shared "global" bucket).
|
||||||
|
-- MySQL treats NULL as distinct in unique indexes, so workspace-wide names
|
||||||
|
-- can technically collide; the service layer enforces uniqueness for the
|
||||||
|
-- NULL-kb_id case in software.
|
||||||
|
CREATE UNIQUE INDEX uk_wtr_kb_name ON mate_wiki_transformation (kb_id, name, deleted);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_wiki_transformation_run (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
transformation_id BIGINT NOT NULL,
|
||||||
|
kb_id BIGINT NOT NULL,
|
||||||
|
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
input_kind VARCHAR(16) NOT NULL,
|
||||||
|
raw_id BIGINT NULL,
|
||||||
|
page_id BIGINT NULL,
|
||||||
|
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
output MEDIUMTEXT,
|
||||||
|
error VARCHAR(2048),
|
||||||
|
model_id BIGINT NULL,
|
||||||
|
|
||||||
|
triggered_by VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||||
|
|
||||||
|
started_at DATETIME(3) NULL,
|
||||||
|
completed_at DATETIME(3) NULL,
|
||||||
|
duration_ms BIGINT NULL,
|
||||||
|
|
||||||
|
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
KEY idx_wtrn_tr (transformation_id, deleted),
|
||||||
|
KEY idx_wtrn_kb (kb_id, deleted),
|
||||||
|
KEY idx_wtrn_raw (raw_id, deleted)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- Two-part follow-up to V105 so a transformation's output can flow back
|
||||||
|
-- into the KB as a first-class artifact. See the h2 sibling migration for
|
||||||
|
-- the prose explanation. MySQL lacks `ADD COLUMN IF NOT EXISTS`, so each
|
||||||
|
-- column is guarded by an INFORMATION_SCHEMA check + prepared statement.
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_wiki_transformation'
|
||||||
|
AND COLUMN_NAME = 'output_target');
|
||||||
|
SET @s := IF(@c = 0,
|
||||||
|
'ALTER TABLE mate_wiki_transformation ADD COLUMN output_target VARCHAR(16) NOT NULL DEFAULT ''none''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_wiki_transformation_run'
|
||||||
|
AND COLUMN_NAME = 'output_page_id');
|
||||||
|
SET @s := IF(@c = 0,
|
||||||
|
'ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_page_id BIGINT NULL',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
You are a content transformation worker. The user supplies (a) a transformation
|
||||||
|
instruction and (b) a source text. Follow the instruction precisely and return
|
||||||
|
only the transformed content.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not add framing such as "Here is the result:" — emit only the transformation output.
|
||||||
|
- If the instruction asks for JSON, return exactly one valid JSON document and nothing else.
|
||||||
|
- Otherwise return Markdown.
|
||||||
|
- Do not invent facts beyond the supplied source text. If the source is empty, return a one-line note saying so.
|
||||||
|
- Preserve the language of the source text unless the instruction explicitly says otherwise.
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
## Instruction
|
||||||
|
|
||||||
|
{instruction}
|
||||||
|
|
||||||
|
## Source — {source_title}
|
||||||
|
|
||||||
|
{source_text}
|
||||||
@ -675,6 +675,46 @@ export const wikiApi = {
|
|||||||
// RFC-032: Search preview
|
// RFC-032: Search preview
|
||||||
searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) =>
|
searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) =>
|
||||||
http.post(`/wiki/kb/${kbId}/search-preview`, data),
|
http.post(`/wiki/kb/${kbId}/search-preview`, data),
|
||||||
|
|
||||||
|
// Transformations: reusable prompt templates run over raw materials
|
||||||
|
listTransformations: (kbId?: number) =>
|
||||||
|
http.get('/wiki/transformations', kbId != null ? { params: { kbId } } : undefined),
|
||||||
|
getTransformation: (id: number) =>
|
||||||
|
http.get(`/wiki/transformations/${id}`),
|
||||||
|
createTransformation: (data: {
|
||||||
|
kbId?: number | null
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
promptTemplate: string
|
||||||
|
applyDefault?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
modelId?: number | null
|
||||||
|
outputTarget?: 'none' | 'page'
|
||||||
|
}) =>
|
||||||
|
http.post('/wiki/transformations', data),
|
||||||
|
updateTransformation: (id: number, data: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
promptTemplate?: string
|
||||||
|
applyDefault?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
modelId?: number | null
|
||||||
|
outputTarget?: 'none' | 'page'
|
||||||
|
}) =>
|
||||||
|
http.put(`/wiki/transformations/${id}`, data),
|
||||||
|
deleteTransformation: (id: number) =>
|
||||||
|
http.delete(`/wiki/transformations/${id}`),
|
||||||
|
applyTransformation: (id: number, rawId: number, sync = true) =>
|
||||||
|
http.post(`/wiki/transformations/${id}/apply`, { rawId }, { params: { sync } }),
|
||||||
|
listTransformationRuns: (params: { rawId?: number; kbId?: number; transformationId?: number; limit?: number }) =>
|
||||||
|
http.get('/wiki/transformations/runs', { params }),
|
||||||
|
getTransformationRun: (runId: number) =>
|
||||||
|
http.get(`/wiki/transformations/runs/${runId}`),
|
||||||
|
deleteTransformationRun: (runId: number) =>
|
||||||
|
http.delete(`/wiki/transformations/runs/${runId}`),
|
||||||
|
saveTransformationRunAsPage: (runId: number) =>
|
||||||
|
http.post(`/wiki/transformations/runs/${runId}/save-as-page`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Workspace (Team) ====================
|
// ==================== Workspace (Team) ====================
|
||||||
|
|||||||
@ -1796,6 +1796,57 @@ export default {
|
|||||||
rawMaterials: 'Raw Materials',
|
rawMaterials: 'Raw Materials',
|
||||||
pages: 'Wiki Pages',
|
pages: 'Wiki Pages',
|
||||||
config: 'Config',
|
config: 'Config',
|
||||||
|
transformations: {
|
||||||
|
tab: 'Transformations',
|
||||||
|
title: 'Transformations',
|
||||||
|
desc: 'Reusable prompt templates. Each template runs against one raw material; the generated content is persisted here. Toggle "apply default" to fire it automatically after every successful ingest.',
|
||||||
|
empty: 'No transformations yet — create one to get started.',
|
||||||
|
createBtn: 'New transformation',
|
||||||
|
editBtn: 'Edit',
|
||||||
|
deleteBtn: 'Delete',
|
||||||
|
deleteConfirm: 'Delete this transformation? Past run history is kept.',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
applyDefault: 'Apply by default',
|
||||||
|
applyDefaultOn: 'Runs automatically after each ingest completes',
|
||||||
|
applyDefaultOff: 'Manual or agent-tool runs only',
|
||||||
|
outputTargetLabel: 'Output target',
|
||||||
|
outputTargetNone: 'None — output stays in run history',
|
||||||
|
outputTargetPage: 'Save as wiki page (searchable / agent-accessible / linkable)',
|
||||||
|
outputTargetPageBadge: 'Auto-save to page',
|
||||||
|
saveAsPageBtn: 'Save as page',
|
||||||
|
saving: 'Saving…',
|
||||||
|
savedAsPage: 'Saved as:',
|
||||||
|
saveAsPageDone: 'Saved as wiki page',
|
||||||
|
saveAsPageFailed: 'Save as page failed',
|
||||||
|
openPage: 'Open page',
|
||||||
|
scope: 'Scope',
|
||||||
|
scopeKb: 'This knowledge base only',
|
||||||
|
scopeWorkspace: 'Workspace-wide',
|
||||||
|
name: 'Name',
|
||||||
|
namePlaceholder: 'e.g. risk-extract',
|
||||||
|
nameHelp: 'Used by agent tools; lowercase letters / digits / hyphens',
|
||||||
|
title2: 'Title',
|
||||||
|
titlePlaceholder: 'e.g. Contract risk extraction',
|
||||||
|
description: 'Description',
|
||||||
|
descriptionPlaceholder: 'One line about what this transformation produces',
|
||||||
|
prompt: 'Prompt template',
|
||||||
|
promptHelp: 'Supports {input_text} and {title} placeholders',
|
||||||
|
saveBtn: 'Save',
|
||||||
|
cancelBtn: 'Cancel',
|
||||||
|
runs: 'Run history',
|
||||||
|
runOn: 'Ran on',
|
||||||
|
runStatus: 'Status',
|
||||||
|
runDuration: 'Duration',
|
||||||
|
runOutput: 'Output',
|
||||||
|
runError: 'Error',
|
||||||
|
runApply: 'Run',
|
||||||
|
running: 'Running…',
|
||||||
|
runFailed: 'Run failed',
|
||||||
|
pickRaw: 'Pick a raw material',
|
||||||
|
pickRawHint: 'Select a raw material to use as input',
|
||||||
|
noRuns: 'No runs yet.',
|
||||||
|
},
|
||||||
hotCache: {
|
hotCache: {
|
||||||
tab: 'Hot Cache',
|
tab: 'Hot Cache',
|
||||||
title: 'Recent Activity Snapshot (Hot Cache)',
|
title: 'Recent Activity Snapshot (Hot Cache)',
|
||||||
|
|||||||
@ -1808,6 +1808,57 @@ export default {
|
|||||||
rawMaterials: '原始材料',
|
rawMaterials: '原始材料',
|
||||||
pages: 'Wiki 页面',
|
pages: 'Wiki 页面',
|
||||||
config: '处理配置',
|
config: '处理配置',
|
||||||
|
transformations: {
|
||||||
|
tab: '加工器',
|
||||||
|
title: '加工器(Transformations)',
|
||||||
|
desc: '可复用的 prompt 模板。每个模板针对单个原始材料运行,生成的内容会落到此处可查。开启「默认运行」后,材料处理完成会自动触发。',
|
||||||
|
empty: '尚无加工器模板,新建一个开始。',
|
||||||
|
createBtn: '新建加工器',
|
||||||
|
editBtn: '编辑',
|
||||||
|
deleteBtn: '删除',
|
||||||
|
deleteConfirm: '确认删除该加工器?历史运行记录会保留。',
|
||||||
|
enabled: '启用',
|
||||||
|
disabled: '已停用',
|
||||||
|
applyDefault: '默认运行',
|
||||||
|
applyDefaultOn: '每次新材料完成后自动跑',
|
||||||
|
applyDefaultOff: '仅在手动 / Agent 调用时运行',
|
||||||
|
outputTargetLabel: '输出去向',
|
||||||
|
outputTargetNone: '不保存(仅留在运行历史)',
|
||||||
|
outputTargetPage: '保存为 Wiki 页面(可被搜索 / Agent / 关系图引用)',
|
||||||
|
outputTargetPageBadge: '自动落页',
|
||||||
|
saveAsPageBtn: '保存为页面',
|
||||||
|
saving: '保存中…',
|
||||||
|
savedAsPage: '已保存:',
|
||||||
|
saveAsPageDone: '已保存为 Wiki 页面',
|
||||||
|
saveAsPageFailed: '保存为页面失败',
|
||||||
|
openPage: '查看页面',
|
||||||
|
scope: '范围',
|
||||||
|
scopeKb: '仅限此知识库',
|
||||||
|
scopeWorkspace: '工作区全局',
|
||||||
|
name: '标识名',
|
||||||
|
namePlaceholder: '例如:risk-extract',
|
||||||
|
nameHelp: '只用于 Agent 调用,小写字母 / 数字 / 连字符',
|
||||||
|
title2: '显示名',
|
||||||
|
titlePlaceholder: '例如:合同风险点提取',
|
||||||
|
description: '描述',
|
||||||
|
descriptionPlaceholder: '一句话说明这个加工器是做什么的',
|
||||||
|
prompt: '提示词模板',
|
||||||
|
promptHelp: '可使用 {input_text} 和 {title} 占位符',
|
||||||
|
saveBtn: '保存',
|
||||||
|
cancelBtn: '取消',
|
||||||
|
runs: '运行历史',
|
||||||
|
runOn: '运行于',
|
||||||
|
runStatus: '状态',
|
||||||
|
runDuration: '耗时',
|
||||||
|
runOutput: '输出',
|
||||||
|
runError: '错误',
|
||||||
|
runApply: '运行',
|
||||||
|
running: '运行中…',
|
||||||
|
runFailed: '运行失败',
|
||||||
|
pickRaw: '选择材料',
|
||||||
|
pickRawHint: '从知识库的原始材料中选择一个作为输入',
|
||||||
|
noRuns: '尚无运行记录。',
|
||||||
|
},
|
||||||
hotCache: {
|
hotCache: {
|
||||||
tab: '近况快照',
|
tab: '近况快照',
|
||||||
title: '近况快照(Hot Cache)',
|
title: '近况快照(Hot Cache)',
|
||||||
|
|||||||
701
mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
Normal file
701
mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
<template>
|
||||||
|
<div class="transformations-panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3 class="panel-title">{{ t('wiki.transformations.title') }}</h3>
|
||||||
|
<p class="panel-desc">{{ t('wiki.transformations.desc') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-primary" :disabled="!store.currentKB" @click="openCreate">
|
||||||
|
+ {{ t('wiki.transformations.createBtn') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-row">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="state-row state-row--error">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn-secondary" @click="loadAll">{{ t('common.retry', 'Retry') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="templates.length === 0" class="state-row state-row--empty">
|
||||||
|
<span>{{ t('wiki.transformations.empty') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="templates-list">
|
||||||
|
<article v-for="tpl in templates" :key="tpl.id" class="template-card">
|
||||||
|
<div class="template-head">
|
||||||
|
<div class="template-title">
|
||||||
|
<span class="template-name">{{ tpl.title }}</span>
|
||||||
|
<span class="template-handle">{{ tpl.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="template-flags">
|
||||||
|
<span class="flag" :class="{ 'flag--on': tpl.applyDefault }">
|
||||||
|
{{ tpl.applyDefault ? t('wiki.transformations.applyDefaultOn') : t('wiki.transformations.applyDefaultOff') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="tpl.outputTarget === 'page'" class="flag flag--on">
|
||||||
|
{{ t('wiki.transformations.outputTargetPageBadge') }}
|
||||||
|
</span>
|
||||||
|
<span class="flag" :class="{ 'flag--muted': tpl.enabled === false }">
|
||||||
|
{{ tpl.enabled === false ? t('wiki.transformations.disabled') : t('wiki.transformations.enabled') }}
|
||||||
|
</span>
|
||||||
|
<span class="flag flag--scope">
|
||||||
|
{{ tpl.kbId ? t('wiki.transformations.scopeKb') : t('wiki.transformations.scopeWorkspace') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="tpl.description" class="template-desc">{{ tpl.description }}</p>
|
||||||
|
|
||||||
|
<div class="template-actions">
|
||||||
|
<select
|
||||||
|
class="raw-select"
|
||||||
|
v-model="selectedRawByTemplate[tpl.id]"
|
||||||
|
>
|
||||||
|
<option :value="null" disabled>{{ t('wiki.transformations.pickRaw') }}</option>
|
||||||
|
<option v-for="r in completedRaws" :key="r.id" :value="r.id">
|
||||||
|
{{ r.title || `raw#${r.id}` }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!selectedRawByTemplate[tpl.id] || runningTemplateId === tpl.id"
|
||||||
|
@click="onApply(tpl)"
|
||||||
|
>
|
||||||
|
<span v-if="runningTemplateId === tpl.id">{{ t('wiki.transformations.running') }}</span>
|
||||||
|
<span v-else>{{ t('wiki.transformations.runApply') }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="openEdit(tpl)">{{ t('wiki.transformations.editBtn') }}</button>
|
||||||
|
<button class="btn-secondary btn-danger" @click="onDelete(tpl)">{{ t('wiki.transformations.deleteBtn') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="runsByTemplate[tpl.id]?.length" class="runs-list">
|
||||||
|
<div class="runs-title">{{ t('wiki.transformations.runs') }}</div>
|
||||||
|
<details v-for="run in runsByTemplate[tpl.id]" :key="run.id" class="run-item">
|
||||||
|
<summary>
|
||||||
|
<span class="run-status" :class="`run-status--${run.status}`">{{ run.status }}</span>
|
||||||
|
<span class="run-meta">
|
||||||
|
{{ rawTitleFor(run.rawId) }}
|
||||||
|
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
|
||||||
|
· {{ formatDuration(run.durationMs) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="run.outputPageId" class="run-saved-badge">
|
||||||
|
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<template v-if="run.status === 'completed'">
|
||||||
|
<div class="run-actions">
|
||||||
|
<button
|
||||||
|
v-if="!run.outputPageId"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="savingRunId === run.id"
|
||||||
|
@click="onSaveRunAsPage(tpl, run)"
|
||||||
|
>
|
||||||
|
{{ savingRunId === run.id
|
||||||
|
? t('wiki.transformations.saving')
|
||||||
|
: t('wiki.transformations.saveAsPageBtn') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="onOpenSavedPage(run)"
|
||||||
|
>
|
||||||
|
{{ t('wiki.transformations.openPage') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="run-output">{{ run.output }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="run.status === 'failed'" class="run-error">{{ run.error }}</div>
|
||||||
|
<div v-else class="run-output run-output--muted">{{ t('wiki.transformations.running') }}</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create / edit modal -->
|
||||||
|
<div v-if="editorOpen" class="modal-overlay" @click.self="closeEditor">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ editing ? t('wiki.transformations.editBtn') : t('wiki.transformations.createBtn') }}</h3>
|
||||||
|
<button class="modal-close" @click="closeEditor">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{{ t('wiki.transformations.name') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
:placeholder="t('wiki.transformations.namePlaceholder')"
|
||||||
|
:disabled="!!editing"
|
||||||
|
class="field-input"
|
||||||
|
/>
|
||||||
|
<span class="field-hint">{{ t('wiki.transformations.nameHelp') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{{ t('wiki.transformations.title2') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
:placeholder="t('wiki.transformations.titlePlaceholder')"
|
||||||
|
class="field-input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{{ t('wiki.transformations.description') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="form.description"
|
||||||
|
:placeholder="t('wiki.transformations.descriptionPlaceholder')"
|
||||||
|
class="field-input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">{{ t('wiki.transformations.prompt') }}</span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.promptTemplate"
|
||||||
|
class="field-textarea"
|
||||||
|
rows="10"
|
||||||
|
></textarea>
|
||||||
|
<span class="field-hint">{{ t('wiki.transformations.promptHelp') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" v-model="form.applyDefault" />
|
||||||
|
<span>{{ t('wiki.transformations.applyDefault') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" v-model="form.enabled" />
|
||||||
|
<span>{{ t('wiki.transformations.enabled') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="field field--group">
|
||||||
|
<legend class="field-label">{{ t('wiki.transformations.outputTargetLabel') }}</legend>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="none" v-model="form.outputTarget" />
|
||||||
|
<span>{{ t('wiki.transformations.outputTargetNone') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" value="page" v-model="form.outputTarget" />
|
||||||
|
<span>{{ t('wiki.transformations.outputTargetPage') }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" @click="closeEditor">{{ t('wiki.transformations.cancelBtn') }}</button>
|
||||||
|
<button class="btn-primary" :disabled="saving" @click="onSave">
|
||||||
|
{{ saving ? t('common.loading') : t('wiki.transformations.saveBtn') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElIcon, ElMessage } from 'element-plus'
|
||||||
|
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||||
|
import { wikiApi } from '@/api/index'
|
||||||
|
import { useWikiStore, type WikiRawMaterial } from '@/stores/useWikiStore'
|
||||||
|
|
||||||
|
interface WikiTransformation {
|
||||||
|
id: number
|
||||||
|
kbId: number | null
|
||||||
|
workspaceId: number
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
promptTemplate: string
|
||||||
|
applyDefault: boolean
|
||||||
|
enabled: boolean
|
||||||
|
modelId: number | null
|
||||||
|
outputTarget: 'none' | 'page' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WikiTransformationRun {
|
||||||
|
id: number
|
||||||
|
transformationId: number
|
||||||
|
kbId: number
|
||||||
|
rawId: number | null
|
||||||
|
pageId: number | null
|
||||||
|
inputKind: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
output: string | null
|
||||||
|
error: string | null
|
||||||
|
durationMs: number | null
|
||||||
|
startedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
createTime: string
|
||||||
|
triggeredBy: string
|
||||||
|
outputPageId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useWikiStore()
|
||||||
|
|
||||||
|
const templates = ref<WikiTransformation[]>([])
|
||||||
|
const runsByTemplate = ref<Record<number, WikiTransformationRun[]>>({})
|
||||||
|
const selectedRawByTemplate = ref<Record<number, number | null>>({})
|
||||||
|
const runningTemplateId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editing = ref<WikiTransformation | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const savingRunId = ref<number | null>(null)
|
||||||
|
const form = reactive<{
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
promptTemplate: string
|
||||||
|
applyDefault: boolean
|
||||||
|
enabled: boolean
|
||||||
|
outputTarget: 'none' | 'page'
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
promptTemplate: '',
|
||||||
|
applyDefault: false,
|
||||||
|
enabled: true,
|
||||||
|
outputTarget: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedRaws = computed<WikiRawMaterial[]>(() =>
|
||||||
|
store.rawMaterials.filter(
|
||||||
|
(r) => r.processingStatus === 'completed' || r.processingStatus === 'partial'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
function rawTitleFor(rawId: number | null): string {
|
||||||
|
if (rawId == null) return '—'
|
||||||
|
const r = store.rawMaterials.find((x) => x.id === rawId)
|
||||||
|
return r?.title || `raw#${rawId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (ms == null) return '—'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const resp: any = await wikiApi.listTransformations(store.currentKB.id)
|
||||||
|
templates.value = resp?.data ?? []
|
||||||
|
selectedRawByTemplate.value = Object.fromEntries(
|
||||||
|
templates.value.map((t) => [t.id, selectedRawByTemplate.value[t.id] ?? null])
|
||||||
|
)
|
||||||
|
await Promise.all(templates.value.map((tpl) => loadRunsFor(tpl.id)))
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRunsFor(templateId: number) {
|
||||||
|
try {
|
||||||
|
const resp: any = await wikiApi.listTransformationRuns({ transformationId: templateId, limit: 10 })
|
||||||
|
runsByTemplate.value[templateId] = resp?.data ?? []
|
||||||
|
} catch {
|
||||||
|
runsByTemplate.value[templateId] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.title = ''
|
||||||
|
form.description = ''
|
||||||
|
form.promptTemplate = 'Summarize the source below as 3 key bullet points and 1 sentence of overall takeaway.\n\nSource:\n{input_text}'
|
||||||
|
form.applyDefault = false
|
||||||
|
form.enabled = true
|
||||||
|
form.outputTarget = 'none'
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tpl: WikiTransformation) {
|
||||||
|
editing.value = tpl
|
||||||
|
form.name = tpl.name
|
||||||
|
form.title = tpl.title
|
||||||
|
form.description = tpl.description || ''
|
||||||
|
form.promptTemplate = tpl.promptTemplate
|
||||||
|
form.applyDefault = tpl.applyDefault
|
||||||
|
form.enabled = tpl.enabled !== false
|
||||||
|
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editorOpen.value = false
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!store.currentKB) return
|
||||||
|
if (!form.name.trim() || !form.title.trim() || !form.promptTemplate.trim()) {
|
||||||
|
ElMessage.warning(t('common.required', 'All fields are required'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await wikiApi.updateTransformation(editing.value.id, {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
promptTemplate: form.promptTemplate,
|
||||||
|
applyDefault: form.applyDefault,
|
||||||
|
enabled: form.enabled,
|
||||||
|
outputTarget: form.outputTarget,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await wikiApi.createTransformation({
|
||||||
|
kbId: store.currentKB.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description,
|
||||||
|
promptTemplate: form.promptTemplate,
|
||||||
|
applyDefault: form.applyDefault,
|
||||||
|
enabled: form.enabled,
|
||||||
|
outputTarget: form.outputTarget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
closeEditor()
|
||||||
|
await loadAll()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? String(e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(tpl: WikiTransformation) {
|
||||||
|
if (!confirm(t('wiki.transformations.deleteConfirm'))) return
|
||||||
|
try {
|
||||||
|
await wikiApi.deleteTransformation(tpl.id)
|
||||||
|
await loadAll()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onApply(tpl: WikiTransformation) {
|
||||||
|
const rawId = selectedRawByTemplate.value[tpl.id]
|
||||||
|
if (!rawId) return
|
||||||
|
runningTemplateId.value = tpl.id
|
||||||
|
try {
|
||||||
|
await wikiApi.applyTransformation(tpl.id, rawId, true)
|
||||||
|
await loadRunsFor(tpl.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? t('wiki.transformations.runFailed'))
|
||||||
|
} finally {
|
||||||
|
runningTemplateId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveRunAsPage(tpl: WikiTransformation, run: WikiTransformationRun) {
|
||||||
|
if (run.status !== 'completed' || !run.output) return
|
||||||
|
savingRunId.value = run.id
|
||||||
|
try {
|
||||||
|
const resp: any = await wikiApi.saveTransformationRunAsPage(run.id)
|
||||||
|
const payload = resp?.data ?? {}
|
||||||
|
if (payload.pageId) {
|
||||||
|
run.outputPageId = payload.pageId
|
||||||
|
}
|
||||||
|
ElMessage.success(t('wiki.transformations.saveAsPageDone'))
|
||||||
|
// Refresh the page list in the wiki store so the new page is visible in
|
||||||
|
// the sidebar / search results without a manual reload.
|
||||||
|
if (store.currentKB) {
|
||||||
|
await store.fetchPages(store.currentKB.id)
|
||||||
|
}
|
||||||
|
await loadRunsFor(tpl.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? t('wiki.transformations.saveAsPageFailed'))
|
||||||
|
} finally {
|
||||||
|
savingRunId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenSavedPage(run: WikiTransformationRun) {
|
||||||
|
if (!run.outputPageId || !store.currentKB) return
|
||||||
|
try {
|
||||||
|
// Resolve slug from the run's outputPageId by checking the local store
|
||||||
|
// first; if not loaded yet, fetch pages.
|
||||||
|
if (store.pages.length === 0) {
|
||||||
|
await store.fetchPages(store.currentKB.id)
|
||||||
|
}
|
||||||
|
const page = store.pages.find((p) => p.id === run.outputPageId)
|
||||||
|
if (page) {
|
||||||
|
await store.loadPage(store.currentKB.id, page.slug)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => store.currentKB?.id, async (newId, oldId) => {
|
||||||
|
if (newId === oldId) return
|
||||||
|
templates.value = []
|
||||||
|
runsByTemplate.value = {}
|
||||||
|
selectedRawByTemplate.value = {}
|
||||||
|
if (store.currentKB) {
|
||||||
|
// Raw materials are loaded by the parent workspace, but if this tab is
|
||||||
|
// opened before the raw panel mounts, force a refresh so the picker has
|
||||||
|
// something to show.
|
||||||
|
if (store.rawMaterials.length === 0) {
|
||||||
|
try { await store.fetchRawMaterials(store.currentKB.id) } catch {}
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (store.currentKB && store.rawMaterials.length === 0) {
|
||||||
|
try { await store.fetchRawMaterials(store.currentKB.id) } catch {}
|
||||||
|
}
|
||||||
|
await loadAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transformations-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.header-text { flex: 1; min-width: 240px; }
|
||||||
|
.header-actions { display: flex; gap: 8px; }
|
||||||
|
.panel-title { font-size: 18px; font-weight: 700; margin: 0 0 4px; color: var(--mc-text-primary); }
|
||||||
|
.panel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.5; }
|
||||||
|
|
||||||
|
.btn-primary, .btn-secondary {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--mc-primary); color: white; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border-color: var(--mc-border); }
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: var(--mc-bg-sunken); }
|
||||||
|
.btn-danger:hover { color: var(--el-color-danger); border-color: var(--el-color-danger-light-5); }
|
||||||
|
|
||||||
|
.state-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.state-row--error { color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
|
||||||
|
.state-row--empty { justify-content: center; }
|
||||||
|
|
||||||
|
.templates-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.template-card {
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.template-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.template-title { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.template-name { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); }
|
||||||
|
.template-handle { font-size: 12px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); }
|
||||||
|
.template-flags { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-start; }
|
||||||
|
.flag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.flag--on { color: var(--mc-primary); border-color: var(--mc-primary); }
|
||||||
|
.flag--muted { color: var(--mc-text-tertiary); }
|
||||||
|
.flag--scope { background: transparent; }
|
||||||
|
|
||||||
|
.template-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.5; }
|
||||||
|
|
||||||
|
.template-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.raw-select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runs-list { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px dashed var(--mc-border-light); }
|
||||||
|
.runs-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mc-text-tertiary); }
|
||||||
|
.run-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.run-status {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.run-status--completed { background: var(--el-color-success-light-9); color: var(--el-color-success); }
|
||||||
|
.run-status--failed { background: var(--el-color-danger-light-9); color: var(--el-color-danger); }
|
||||||
|
.run-status--running, .run-status--pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
|
||||||
|
.run-meta { color: var(--mc-text-tertiary); }
|
||||||
|
.run-output {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace);
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.run-output--muted { color: var(--mc-text-tertiary); font-style: italic; }
|
||||||
|
.run-actions { display: flex; gap: 8px; padding: 8px 0 6px; }
|
||||||
|
.run-saved-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: white;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.run-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(640px, 90vw);
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
.modal-head h3 { margin: 0; font-size: 15px; }
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.modal-body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.field-label { font-size: 12px; color: var(--mc-text-secondary); font-weight: 500; }
|
||||||
|
.field-input, .field-textarea {
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field-textarea { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); resize: vertical; }
|
||||||
|
.field-hint { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||||
|
.field-row { display: flex; gap: 16px; }
|
||||||
|
.check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
|
||||||
|
.field--group {
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field--group legend { padding: 0 4px; font-size: 12px; color: var(--mc-text-secondary); font-weight: 500; }
|
||||||
|
.radio-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--mc-text-primary); cursor: pointer; }
|
||||||
|
</style>
|
||||||
@ -42,6 +42,10 @@
|
|||||||
<div v-if="activeTab === 'hotCache'" class="tab-content tab-content--hot-cache">
|
<div v-if="activeTab === 'hotCache'" class="tab-content tab-content--hot-cache">
|
||||||
<HotCachePanel />
|
<HotCachePanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'transformations'" class="tab-content">
|
||||||
|
<TransformationsPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,6 +61,7 @@ import WikiPageViewer from './WikiPageViewer.vue'
|
|||||||
import WikiConfig from './WikiConfig.vue'
|
import WikiConfig from './WikiConfig.vue'
|
||||||
import WikiGraphView from './WikiGraphView.vue'
|
import WikiGraphView from './WikiGraphView.vue'
|
||||||
import HotCachePanel from './HotCachePanel.vue'
|
import HotCachePanel from './HotCachePanel.vue'
|
||||||
|
import TransformationsPanel from './TransformationsPanel.vue'
|
||||||
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
||||||
import WikiPageSidebar from './WikiPageSidebar.vue'
|
import WikiPageSidebar from './WikiPageSidebar.vue'
|
||||||
|
|
||||||
@ -72,6 +77,7 @@ const tabs = computed(() => [
|
|||||||
{ key: 'pages', label: t('wiki.pages') },
|
{ key: 'pages', label: t('wiki.pages') },
|
||||||
{ key: 'graph', label: t('wiki.graph.tab') },
|
{ key: 'graph', label: t('wiki.graph.tab') },
|
||||||
{ key: 'config', label: t('wiki.config') },
|
{ key: 'config', label: t('wiki.config') },
|
||||||
|
{ key: 'transformations', label: t('wiki.transformations.tab') },
|
||||||
{ key: 'hotCache', label: t('wiki.hotCache.tab') },
|
{ key: 'hotCache', label: t('wiki.hotCache.tab') },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user