mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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)
|
||||
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) */
|
||||
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()));
|
||||
}
|
||||
|
||||
// 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={}",
|
||||
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.WikiPageEntity;
|
||||
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.service.*;
|
||||
|
||||
@ -59,6 +61,13 @@ public class WikiTool {
|
||||
@Autowired(required = false)
|
||||
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,
|
||||
WikiKnowledgeBaseService kbService,
|
||||
WikiRawMaterialService rawService,
|
||||
@ -632,6 +641,78 @@ public class WikiTool {
|
||||
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 ====================
|
||||
|
||||
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
|
||||
searchPreview: (kbId: number, data: { query: string; mode?: string; topK?: number }) =>
|
||||
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) ====================
|
||||
|
||||
@ -1796,6 +1796,57 @@ export default {
|
||||
rawMaterials: 'Raw Materials',
|
||||
pages: 'Wiki Pages',
|
||||
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: {
|
||||
tab: 'Hot Cache',
|
||||
title: 'Recent Activity Snapshot (Hot Cache)',
|
||||
|
||||
@ -1808,6 +1808,57 @@ export default {
|
||||
rawMaterials: '原始材料',
|
||||
pages: 'Wiki 页面',
|
||||
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: {
|
||||
tab: '近况快照',
|
||||
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">
|
||||
<HotCachePanel />
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'transformations'" class="tab-content">
|
||||
<TransformationsPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,6 +61,7 @@ import WikiPageViewer from './WikiPageViewer.vue'
|
||||
import WikiConfig from './WikiConfig.vue'
|
||||
import WikiGraphView from './WikiGraphView.vue'
|
||||
import HotCachePanel from './HotCachePanel.vue'
|
||||
import TransformationsPanel from './TransformationsPanel.vue'
|
||||
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
|
||||
import WikiPageSidebar from './WikiPageSidebar.vue'
|
||||
|
||||
@ -72,6 +77,7 @@ const tabs = computed(() => [
|
||||
{ key: 'pages', label: t('wiki.pages') },
|
||||
{ key: 'graph', label: t('wiki.graph.tab') },
|
||||
{ key: 'config', label: t('wiki.config') },
|
||||
{ key: 'transformations', label: t('wiki.transformations.tab') },
|
||||
{ key: 'hotCache', label: t('wiki.hotCache.tab') },
|
||||
])
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user