feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -593,6 +593,12 @@ export function reportDownloadUrl(taskId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
|
||||||
|
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀。主题为空则退回 task_id。
|
||||||
|
export function reportFilename(topic: string, id: string, ext: string): string {
|
||||||
|
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
|
||||||
|
return `${base}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
|
||||||
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
|
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
|
||||||
}
|
}
|
||||||
@@ -674,6 +680,7 @@ export interface RunSummary {
|
|||||||
at: string;
|
at: string;
|
||||||
eval_level: string;
|
eval_level: string;
|
||||||
eval_overall: number;
|
eval_overall: number;
|
||||||
|
topic: string; // 报告类运行的主题;普通任务为空串
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRuns(limit = 30): Promise<RunSummary[]> {
|
export async function listRuns(limit = 30): Promise<RunSummary[]> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { ExecEvent } from "./api";
|
import type { ExecEvent } from "./api";
|
||||||
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow } from "./run";
|
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow, isReportRun } from "./run";
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
||||||
@@ -218,3 +218,19 @@ describe("teamNow(并发时间轴的右端)", () => {
|
|||||||
expect(t.t1 - t.t0).toBe(2000);
|
expect(t.t1 - t.t0).toBe(2000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isReportRun(运行是不是报告生成)", () => {
|
||||||
|
it("report_ 前缀 → true", () => {
|
||||||
|
expect(isReportRun("report_5dae9155af5cb500")).toBe(true);
|
||||||
|
});
|
||||||
|
it("普通任务 → false", () => {
|
||||||
|
expect(isReportRun("task_e984b1f3e38a35c7")).toBe(false);
|
||||||
|
});
|
||||||
|
it("空/未定义 → false(当前无选中运行时不该冒出导出按钮)", () => {
|
||||||
|
expect(isReportRun(undefined)).toBe(false);
|
||||||
|
expect(isReportRun("")).toBe(false);
|
||||||
|
});
|
||||||
|
it("不能只看是否包含 report(避免误判)", () => {
|
||||||
|
expect(isReportRun("task_report_like")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ function splitBrief(detail?: string): { brief?: string; output?: string } {
|
|||||||
return { output: detail };
|
return { output: detail };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isReportRun 判定一次运行是不是「报告生成」。
|
||||||
|
// 靠 task_id 前缀:后端 newReportID() 出 "report_<hex>"、普通任务是 "task_<hex>",
|
||||||
|
// 而报告导出接口 /reports/:id/export 本来就按同一个 id 寻址——这个前缀是既有契约,不是我临时约的。
|
||||||
|
export function isReportRun(taskId: string | undefined): boolean {
|
||||||
|
return !!taskId && taskId.startsWith("report_");
|
||||||
|
}
|
||||||
|
|
||||||
// teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长;
|
// teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长;
|
||||||
// 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的),
|
// 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的),
|
||||||
// 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。
|
// 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
|
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
|
||||||
import { generateReport, streamTokens, streamExec, reportExportUrl, type Identity, type ExecEvent } from "../lib/api";
|
import { generateReport, streamTokens, streamExec, reportExportUrl, reportFilename, type Identity, type ExecEvent } from "../lib/api";
|
||||||
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
|
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
||||||
|
|
||||||
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀。
|
|
||||||
function reportFilename(topic: string, id: string, ext: string): string {
|
|
||||||
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
|
|
||||||
return `${base}.${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Phase = "idle" | "running" | "done" | "error";
|
type Phase = "idle" | "running" | "done" | "error";
|
||||||
|
|
||||||
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Activity, FileText, History, Users, Wrench } from "lucide-react";
|
import { Activity, FileText, FileType2, FileCode, History, Users, Wrench } from "lucide-react";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
import { TeamView } from "../components/TeamView";
|
import { TeamView } from "../components/TeamView";
|
||||||
import { OfficeView } from "../components/OfficeView";
|
import { OfficeView } from "../components/OfficeView";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { ChartView } from "../components/ChartView";
|
import { ChartView } from "../components/ChartView";
|
||||||
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
||||||
import { deriveNodes, isMultiAgent, emptyRun, type RunState } from "../lib/run";
|
import { deriveNodes, isMultiAgent, isReportRun, emptyRun, type RunState } from "../lib/run";
|
||||||
import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api";
|
import { listRuns, taskEval, runReplay, reportExportUrl, reportFilename, type RunSummary, type EvalResult } from "../lib/api";
|
||||||
import { Tabs, Panel, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
|
import { Tabs, Panel, Dot, Badge, Button, EmptyState, useToast, cn, type TabDef } from "../ui";
|
||||||
|
import { saveReportAs } from "../lib/desktop";
|
||||||
|
|
||||||
type DetailTab = "trace" | "team" | "tools" | "eval";
|
type DetailTab = "trace" | "team" | "tools" | "eval";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ function relTime(iso: string): string {
|
|||||||
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
|
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
|
||||||
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
|
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
|
||||||
export function RunsView({ run }: { run: RunState }) {
|
export function RunsView({ run }: { run: RunState }) {
|
||||||
|
const toast = useToast();
|
||||||
const [runs, setRuns] = useState<RunSummary[]>([]);
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
||||||
const [replay, setReplay] = useState<RunState>(emptyRun);
|
const [replay, setReplay] = useState<RunState>(emptyRun);
|
||||||
@@ -68,6 +70,19 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
|
|
||||||
const liveActive = run.taskId && run.phase !== "idle";
|
const liveActive = run.taskId && run.phase !== "idle";
|
||||||
const cur = sel ? replay : run;
|
const cur = sel ? replay : run;
|
||||||
|
// 报告类运行在这里也能导出:所有执行归口运行页,只能看不能存就不算归口。
|
||||||
|
// 报告页的导出按钮依赖它自己那份一切页面就没的本地状态,找回来的报告只能在这儿导。
|
||||||
|
const curId = sel ?? run.taskId ?? "";
|
||||||
|
const isReport = isReportRun(curId);
|
||||||
|
const curTopic = runs.find((r) => r.task_id === curId)?.topic ?? "";
|
||||||
|
const exportReport = async (format: "docx" | "md") => {
|
||||||
|
try {
|
||||||
|
const p = await saveReportAs(reportExportUrl(curId, format), reportFilename(curTopic, curId, format));
|
||||||
|
if (p) toast.push("success", "已保存到 " + p);
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
const nodes = deriveNodes(cur.exec);
|
const nodes = deriveNodes(cur.exec);
|
||||||
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。
|
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。
|
||||||
const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent");
|
const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent");
|
||||||
@@ -119,7 +134,12 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === r.task_id ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
|
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === r.task_id ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
|
||||||
<Dot tone={STATUS_DOT[r.status] ?? "neutral"} />
|
<Dot tone={STATUS_DOT[r.status] ?? "neutral"} />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
|
{r.topic ? (
|
||||||
|
// 报告类运行显示主题——列表里挂个 report_<hex> 谁也认不出是哪份报告。
|
||||||
|
<span className="block truncate text-[11px] text-slate-300">{r.topic}</span>
|
||||||
|
) : (
|
||||||
<span className="block truncate font-mono text-[11px] text-slate-300">{r.task_id}</span>
|
<span className="block truncate font-mono text-[11px] text-slate-300">{r.task_id}</span>
|
||||||
|
)}
|
||||||
<span className="block text-[10px] text-slate-600">{r.status} · {relTime(r.at)}</span>
|
<span className="block text-[10px] text-slate-600">{r.status} · {relTime(r.at)}</span>
|
||||||
</span>
|
</span>
|
||||||
{r.eval_level && <Badge tone={LEVEL_TONE[r.eval_level] ?? "neutral"}>{r.eval_overall.toFixed(2)}</Badge>}
|
{r.eval_level && <Badge tone={LEVEL_TONE[r.eval_level] ?? "neutral"}>{r.eval_overall.toFixed(2)}</Badge>}
|
||||||
@@ -167,7 +187,22 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右:模型输出(Markdown,固定面板,与之前一致) */}
|
{/* 右:模型输出(Markdown,固定面板,与之前一致) */}
|
||||||
<Panel title="模型输出" icon={FileText}>
|
<Panel
|
||||||
|
title={isReport ? "报告正文" : "模型输出"}
|
||||||
|
icon={FileText}
|
||||||
|
actions={
|
||||||
|
isReport && cur.output ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button icon={FileType2} onClick={() => exportReport("docx")}>
|
||||||
|
Word
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" icon={FileCode} onClick={() => exportReport("md")}>
|
||||||
|
Markdown
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{cur.output ? (
|
{cur.output ? (
|
||||||
<OutputView output={cur.output} />
|
<OutputView output={cur.output} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ type RunRow struct {
|
|||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
EvalLevel string `json:"eval_level"`
|
EvalLevel string `json:"eval_level"`
|
||||||
EvalOverall float64 `json:"eval_overall"`
|
EvalOverall float64 `json:"eval_overall"`
|
||||||
|
// Topic:报告类运行的主题。报告的 graph 是占位 DSL `{"topic":"…"}`,普通任务的 DSL
|
||||||
|
// 没有顶层 topic → 空串。否则运行历史里报告只能显示 report_<hex> 这种 id,读不出是啥。
|
||||||
|
Topic string `json:"topic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentRuns 返回某用户最近 n 条运行(含评测分级,供「运行历史」列表)。
|
// RecentRuns 返回某用户最近 n 条运行(含评测分级,供「运行历史」列表)。
|
||||||
@@ -306,7 +309,8 @@ func (p *Postgres) RecentRuns(ctx context.Context, owner string, n int) []RunRow
|
|||||||
var out []RunRow
|
var out []RunRow
|
||||||
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||||||
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
|
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
|
||||||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall").
|
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
|
||||||
|
"coalesce(t.graph->>'topic','') as topic").
|
||||||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||||||
Where("t.deleted_at is null AND t.owner = ?", owner)
|
Where("t.deleted_at is null AND t.owner = ?", owner)
|
||||||
if tid := tenantFromCtx(ctx); tid != "" && !isSystemCtx(ctx) {
|
if tid := tenantFromCtx(ctx); tid != "" && !isSystemCtx(ctx) {
|
||||||
|
|||||||
Reference in New Issue
Block a user