feat: add reviews and integration health controls
This commit is contained in:
@@ -211,6 +211,30 @@ class Database:
|
|||||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_reviews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
|
source_job_id TEXT,
|
||||||
|
assistant_id TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'douyin',
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'video',
|
||||||
|
publish_url TEXT NOT NULL DEFAULT '',
|
||||||
|
published_at TEXT NOT NULL DEFAULT '',
|
||||||
|
metrics_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
verdict TEXT NOT NULL DEFAULT '',
|
||||||
|
highlights TEXT NOT NULL DEFAULT '',
|
||||||
|
next_actions TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(source_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS job_events (
|
CREATE TABLE IF NOT EXISTS job_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
job_id TEXT NOT NULL,
|
job_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import httpx
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
from fastapi import Body, Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -251,6 +254,36 @@ class AiVideoJobRequest(BaseModel):
|
|||||||
duration: int = 5
|
duration: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewCreateRequest(BaseModel):
|
||||||
|
project_id: str = ""
|
||||||
|
source_job_id: str = ""
|
||||||
|
assistant_id: str = ""
|
||||||
|
title: str = ""
|
||||||
|
platform: str = "douyin"
|
||||||
|
content_type: str = "video"
|
||||||
|
publish_url: str = ""
|
||||||
|
published_at: str = ""
|
||||||
|
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
verdict: str = ""
|
||||||
|
highlights: str = ""
|
||||||
|
next_actions: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewUpdateRequest(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
publish_url: str | None = None
|
||||||
|
published_at: str | None = None
|
||||||
|
metrics: dict[str, Any] | None = None
|
||||||
|
verdict: str | None = None
|
||||||
|
highlights: str | None = None
|
||||||
|
next_actions: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
assistant_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class InternalStepRequest(BaseModel):
|
class InternalStepRequest(BaseModel):
|
||||||
job_id: str = ""
|
job_id: str = ""
|
||||||
jobId: str = ""
|
jobId: str = ""
|
||||||
@@ -521,6 +554,41 @@ def assistant_payload(row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def review_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metrics = parse_json_object(row.get("metrics_json") or "{}")
|
||||||
|
source_job = None
|
||||||
|
assistant = None
|
||||||
|
if row.get("source_job_id"):
|
||||||
|
source_job_row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (row["source_job_id"],))
|
||||||
|
if source_job_row:
|
||||||
|
source_job = job_payload(source_job_row)
|
||||||
|
if row.get("assistant_id"):
|
||||||
|
assistant_row = db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],))
|
||||||
|
if assistant_row:
|
||||||
|
assistant = assistant_payload(assistant_row)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"project_id": row.get("project_id", ""),
|
||||||
|
"source_job_id": row.get("source_job_id", ""),
|
||||||
|
"assistant_id": row.get("assistant_id", ""),
|
||||||
|
"title": row.get("title", ""),
|
||||||
|
"platform": row.get("platform", "douyin"),
|
||||||
|
"content_type": row.get("content_type", "video"),
|
||||||
|
"publish_url": row.get("publish_url", ""),
|
||||||
|
"published_at": row.get("published_at", ""),
|
||||||
|
"metrics": metrics,
|
||||||
|
"verdict": row.get("verdict", ""),
|
||||||
|
"highlights": row.get("highlights", ""),
|
||||||
|
"next_actions": row.get("next_actions", ""),
|
||||||
|
"notes": row.get("notes", ""),
|
||||||
|
"source_job": source_job,
|
||||||
|
"assistant": assistant,
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
|
def document_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
|
analysis_map = parse_json_object(row.get("analysis_json") or "{}")
|
||||||
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
|
source_artifacts = parse_json_object(row.get("source_artifact_json") or "{}")
|
||||||
@@ -1353,6 +1421,44 @@ async def process_job(job_id: str) -> None:
|
|||||||
update_job_state(job_id, status="failed", error=str(exc))
|
update_job_state(job_id, status="failed", error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def probe_tcp(url: str, timeout: float = 3.0) -> dict[str, Any]:
|
||||||
|
if not url:
|
||||||
|
return {"configured": False, "reachable": False, "status_code": 0, "error": "not_configured", "url": ""}
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
if not host:
|
||||||
|
return {"configured": True, "reachable": False, "status_code": 0, "error": "invalid_url", "url": url}
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
sock.connect((host, port))
|
||||||
|
return {"configured": True, "reachable": True, "status_code": 0, "error": "", "url": url}
|
||||||
|
except Exception as exc: # pragma: no cover - operational probe
|
||||||
|
return {"configured": True, "reachable": False, "status_code": 0, "error": str(exc), "url": url}
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]:
|
||||||
|
tcp = probe_tcp(url, timeout=timeout)
|
||||||
|
target_url = urljoin(url if url.endswith("/") else f"{url}/", path.lstrip("/")) if url else ""
|
||||||
|
if not tcp["configured"] or not tcp["reachable"]:
|
||||||
|
if target_url:
|
||||||
|
tcp["url"] = target_url
|
||||||
|
return tcp
|
||||||
|
try:
|
||||||
|
response = httpx.get(target_url or url, timeout=timeout, follow_redirects=True)
|
||||||
|
tcp["status_code"] = response.status_code
|
||||||
|
tcp["reachable"] = response.status_code < 500
|
||||||
|
tcp["error"] = "" if response.status_code < 500 else f"http_{response.status_code}"
|
||||||
|
except Exception as exc: # pragma: no cover - operational probe
|
||||||
|
tcp["reachable"] = False
|
||||||
|
tcp["error"] = str(exc)
|
||||||
|
tcp["url"] = target_url or url
|
||||||
|
return tcp
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
db.init_schema()
|
db.init_schema()
|
||||||
@@ -1374,6 +1480,29 @@ def healthz() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/integrations/health")
|
||||||
|
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||||
|
_ = account
|
||||||
|
return {
|
||||||
|
"cutvideo": {
|
||||||
|
"base_url": CUTVIDEO_BASE_URL,
|
||||||
|
**probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"),
|
||||||
|
},
|
||||||
|
"huobao": {
|
||||||
|
"base_url": HUOBAO_BASE_URL,
|
||||||
|
**probe_http(HUOBAO_BASE_URL, "/health"),
|
||||||
|
},
|
||||||
|
"n8n": {
|
||||||
|
"base_url": N8N_BASE_URL,
|
||||||
|
**probe_http(N8N_BASE_URL, "/healthz"),
|
||||||
|
},
|
||||||
|
"asr": {
|
||||||
|
"base_url": ASR_HTTP_BASE_URL,
|
||||||
|
**probe_tcp(ASR_HTTP_BASE_URL),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def seed_defaults() -> None:
|
def seed_defaults() -> None:
|
||||||
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
|
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
|
||||||
profile_id = make_id("model")
|
profile_id = make_id("model")
|
||||||
@@ -1746,6 +1875,107 @@ def list_knowledge_documents(knowledge_base_id: str, account: dict[str, Any] = D
|
|||||||
return [document_payload(row) for row in rows]
|
return [document_payload(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/reviews")
|
||||||
|
def list_reviews(
|
||||||
|
project_id: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
account: dict[str, Any] = Depends(require_approved),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
clauses = ["user_id = ?"]
|
||||||
|
params: list[Any] = [account["id"]]
|
||||||
|
if project_id is not None:
|
||||||
|
normalized_project = project_id.strip()
|
||||||
|
if normalized_project:
|
||||||
|
clauses.append("project_id = ?")
|
||||||
|
params.append(normalized_project)
|
||||||
|
else:
|
||||||
|
clauses.append("(project_id IS NULL OR project_id = '')")
|
||||||
|
sql = f"SELECT * FROM publish_reviews WHERE {' AND '.join(clauses)} ORDER BY COALESCE(NULLIF(published_at, ''), created_at) DESC, created_at DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
return [review_payload(row) for row in db.fetch_all(sql, tuple(params))]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/reviews")
|
||||||
|
def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||||
|
source_job = None
|
||||||
|
if request.source_job_id.strip():
|
||||||
|
source_job = load_owned_job(request.source_job_id.strip(), account["id"])
|
||||||
|
requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "")
|
||||||
|
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
|
||||||
|
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
|
||||||
|
review_id = make_id("review")
|
||||||
|
title = request.title.strip() or (source_job.get("title", "") if source_job else "")
|
||||||
|
if not title:
|
||||||
|
title = f"{project['name']} 复盘"
|
||||||
|
timestamp = utc_now()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO publish_reviews (
|
||||||
|
id, user_id, project_id, source_job_id, assistant_id, title, platform, content_type,
|
||||||
|
publish_url, published_at, metrics_json, verdict, highlights, next_actions, notes, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
review_id,
|
||||||
|
account["id"],
|
||||||
|
project["id"],
|
||||||
|
source_job["id"] if source_job else None,
|
||||||
|
(assistant or {}).get("id") or None,
|
||||||
|
title,
|
||||||
|
request.platform or "douyin",
|
||||||
|
request.content_type or "video",
|
||||||
|
request.publish_url.strip(),
|
||||||
|
request.published_at.strip(),
|
||||||
|
json.dumps(request.metrics, ensure_ascii=False),
|
||||||
|
request.verdict.strip(),
|
||||||
|
request.highlights.strip(),
|
||||||
|
request.next_actions.strip(),
|
||||||
|
request.notes.strip(),
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||||
|
return review_payload(row)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/v2/reviews/{review_id}")
|
||||||
|
def update_review(review_id: str, request: ReviewUpdateRequest, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
|
||||||
|
current = load_owned_review(review_id, account["id"])
|
||||||
|
assistant_id = current.get("assistant_id") or None
|
||||||
|
if request.assistant_id is not None:
|
||||||
|
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, current.get("project_id", ""))
|
||||||
|
assistant_id = (assistant or {}).get("id") or None
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE publish_reviews
|
||||||
|
SET title = ?, platform = ?, content_type = ?, publish_url = ?, published_at = ?,
|
||||||
|
metrics_json = ?, verdict = ?, highlights = ?, next_actions = ?, notes = ?,
|
||||||
|
assistant_id = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
request.title if request.title is not None else current.get("title", ""),
|
||||||
|
request.platform if request.platform is not None else current.get("platform", "douyin"),
|
||||||
|
request.content_type if request.content_type is not None else current.get("content_type", "video"),
|
||||||
|
request.publish_url if request.publish_url is not None else current.get("publish_url", ""),
|
||||||
|
request.published_at if request.published_at is not None else current.get("published_at", ""),
|
||||||
|
json.dumps(request.metrics if request.metrics is not None else parse_json_object(current.get("metrics_json") or "{}"), ensure_ascii=False),
|
||||||
|
request.verdict if request.verdict is not None else current.get("verdict", ""),
|
||||||
|
request.highlights if request.highlights is not None else current.get("highlights", ""),
|
||||||
|
request.next_actions if request.next_actions is not None else current.get("next_actions", ""),
|
||||||
|
request.notes if request.notes is not None else current.get("notes", ""),
|
||||||
|
assistant_id,
|
||||||
|
utc_now(),
|
||||||
|
review_id,
|
||||||
|
account["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
|
||||||
|
return review_payload(row)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v2/explore/jobs")
|
@app.get("/v2/explore/jobs")
|
||||||
def list_jobs(
|
def list_jobs(
|
||||||
parent_job_id: str | None = Query(default=None),
|
parent_job_id: str | None = Query(default=None),
|
||||||
@@ -2178,6 +2408,13 @@ def load_owned_content_source(source_id: str, account_id: str) -> dict[str, Any]
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def load_owned_review(review_id: str, account_id: str) -> dict[str, Any]:
|
||||||
|
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ? AND user_id = ?", (review_id, account_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Review not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def load_internal_job(job_id: str) -> dict[str, Any]:
|
def load_internal_job(job_id: str) -> dict[str, Any]:
|
||||||
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
row = db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const appState = {
|
|||||||
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
|
lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()),
|
||||||
trackingAccounts: [],
|
trackingAccounts: [],
|
||||||
trackingDigest: null,
|
trackingDigest: null,
|
||||||
|
reviews: [],
|
||||||
|
integrationHealth: null,
|
||||||
busy: false,
|
busy: false,
|
||||||
message: "",
|
message: "",
|
||||||
lastAction: null,
|
lastAction: null,
|
||||||
@@ -98,6 +100,9 @@ function statusTone(status) {
|
|||||||
const normalized = String(status || "").toLowerCase();
|
const normalized = String(status || "").toLowerCase();
|
||||||
if (["completed", "ready", "approved", "ok"].includes(normalized)) return "green";
|
if (["completed", "ready", "approved", "ok"].includes(normalized)) return "green";
|
||||||
if (["failed", "error", "rejected"].includes(normalized)) return "red";
|
if (["failed", "error", "rejected"].includes(normalized)) return "red";
|
||||||
|
if (["worth_scaling", "good_reference"].includes(normalized)) return "green";
|
||||||
|
if (["needs_rework"].includes(normalized)) return "red";
|
||||||
|
if (["hold"].includes(normalized)) return "orange";
|
||||||
if (["running", "processing", "pending", "queued"].includes(normalized)) return "orange";
|
if (["running", "processing", "pending", "queued"].includes(normalized)) return "orange";
|
||||||
return "blue";
|
return "blue";
|
||||||
}
|
}
|
||||||
@@ -489,6 +494,8 @@ async function logoutSession() {
|
|||||||
appState.documents = [];
|
appState.documents = [];
|
||||||
appState.trackingAccounts = [];
|
appState.trackingAccounts = [];
|
||||||
appState.trackingDigest = null;
|
appState.trackingDigest = null;
|
||||||
|
appState.reviews = [];
|
||||||
|
appState.integrationHealth = null;
|
||||||
appState.lastAction = null;
|
appState.lastAction = null;
|
||||||
appState.lastGeneratedCopy = null;
|
appState.lastGeneratedCopy = null;
|
||||||
appState.lastSimilaritySearch = null;
|
appState.lastSimilaritySearch = null;
|
||||||
@@ -547,11 +554,13 @@ async function bootstrap() {
|
|||||||
renderAll();
|
renderAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [dashboard, contentSources, accounts, trackingAccountsPayload] = await Promise.all([
|
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth] = await Promise.all([
|
||||||
storyforgeFetch("/v2/me/dashboard"),
|
storyforgeFetch("/v2/me/dashboard"),
|
||||||
storyforgeFetch("/v2/content-sources").catch(() => []),
|
storyforgeFetch("/v2/content-sources").catch(() => []),
|
||||||
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
|
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
|
||||||
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" }))
|
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })),
|
||||||
|
storyforgeFetch("/v2/reviews").catch(() => []),
|
||||||
|
storyforgeFetch("/v2/integrations/health").catch(() => null)
|
||||||
]);
|
]);
|
||||||
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
|
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
|
||||||
if (trackingCursorLastSeenAt) {
|
if (trackingCursorLastSeenAt) {
|
||||||
@@ -568,6 +577,8 @@ async function bootstrap() {
|
|||||||
appState.accounts = safeArray(accounts);
|
appState.accounts = safeArray(accounts);
|
||||||
appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload);
|
appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload);
|
||||||
appState.trackingDigest = trackingDigest;
|
appState.trackingDigest = trackingDigest;
|
||||||
|
appState.reviews = safeArray(reviews);
|
||||||
|
appState.integrationHealth = integrationHealth;
|
||||||
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
|
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
|
||||||
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
|
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
|
||||||
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
|
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
|
||||||
@@ -650,6 +661,14 @@ function getProjectStats(projectId) {
|
|||||||
return { knowledgeBases, assistants, jobs, sources };
|
return { knowledgeBases, assistants, jobs, sources };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectReviews(projectId) {
|
||||||
|
return safeArray(appState.reviews).filter((item) => item.project_id === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewById(reviewId) {
|
||||||
|
return safeArray(appState.reviews).find((item) => item.id === reviewId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function getContentSourcesForAccount(account) {
|
function getContentSourcesForAccount(account) {
|
||||||
if (!account) return [];
|
if (!account) return [];
|
||||||
const profileUrl = String(account.profile_url || "").trim();
|
const profileUrl = String(account.profile_url || "").trim();
|
||||||
@@ -1383,6 +1402,13 @@ function renderAutomationScreen() {
|
|||||||
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
|
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
|
||||||
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
|
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
|
||||||
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
|
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
|
||||||
|
const integrations = appState.integrationHealth || {};
|
||||||
|
const integrationCards = [
|
||||||
|
{ key: "cutvideo", label: "自动剪辑", hint: "Windows cutvideo" },
|
||||||
|
{ key: "huobao", label: "AI 视频", hint: "huobao-drama" },
|
||||||
|
{ key: "n8n", label: "编排", hint: "n8n workflow" },
|
||||||
|
{ key: "asr", label: "ASR", hint: "转写服务" }
|
||||||
|
];
|
||||||
return screenShell(
|
return screenShell(
|
||||||
"自动流程",
|
"自动流程",
|
||||||
"自动同步、日报生成和失败补跑先统一看这里。",
|
"自动同步、日报生成和失败补跑先统一看这里。",
|
||||||
@@ -1398,6 +1424,26 @@ function renderAutomationScreen() {
|
|||||||
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
|
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel pad" style="margin-top:18px;">
|
||||||
|
<div class="panel-head"><div><h3>集成状态</h3><div class="panel-subtitle">直接看关键依赖是否在线</div></div></div>
|
||||||
|
<div class="layout-grid grid-4" style="margin-top:14px;">
|
||||||
|
${integrationCards.map((item) => {
|
||||||
|
const detail = integrations[item.key] || {};
|
||||||
|
const tone = detail.reachable ? "green" : (detail.configured ? "red" : "orange");
|
||||||
|
const summary = detail.reachable ? "在线" : (detail.configured ? "不可达" : "未配置");
|
||||||
|
return `
|
||||||
|
<div class="queue-card">
|
||||||
|
<h4>${escapeHtml(item.label)}</h4>
|
||||||
|
<p>${escapeHtml(item.hint)}</p>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="tag ${tone}">${escapeHtml(summary)}</span>
|
||||||
|
${detail.status_code ? `<span class="tag">HTTP ${escapeHtml(detail.status_code)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1441,7 +1487,7 @@ function renderPlaybookScreen() {
|
|||||||
return screenShell(
|
return screenShell(
|
||||||
"Agent",
|
"Agent",
|
||||||
"这里接真实 Agent 列表,后面再继续补创建和编辑动作。",
|
"这里接真实 Agent 列表,后面再继续补创建和编辑动作。",
|
||||||
`${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
|
`${button("设主模型", "open-preferred-model")} ${button("新建 Agent", "open-create-assistant")} ${button("生成文案", "open-generate-copy")} ${button("去生产", "goto-production", "primary")}`,
|
||||||
`
|
`
|
||||||
<div class="hero-card">
|
<div class="hero-card">
|
||||||
<h3>Agent 概览</h3>
|
<h3>Agent 概览</h3>
|
||||||
@@ -1589,28 +1635,54 @@ function renderReviewScreen() {
|
|||||||
if (!appState.dashboard) {
|
if (!appState.dashboard) {
|
||||||
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
|
return screenShell("发布与复盘", "先连接后端。", `${button("连接后端", "open-auth", "primary")}`, renderEmptyState("复盘未加载", "登录后这里会先用最近任务生成一版复盘入口。"));
|
||||||
}
|
}
|
||||||
|
const project = getSelectedProject();
|
||||||
const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4);
|
const completed = safeArray(appState.dashboard.recent_jobs).filter((item) => item.status === "completed").slice(0, 4);
|
||||||
|
const reviews = getProjectReviews(project?.id || "").slice(0, 8);
|
||||||
return screenShell(
|
return screenShell(
|
||||||
"发布与复盘",
|
"发布与复盘",
|
||||||
"当前先用最近完成任务承接一版复盘视图。",
|
"先看已保存复盘,再把完成任务转成结构化复盘。",
|
||||||
`${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
|
`${button("写复盘", "open-create-review")} ${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
|
||||||
`
|
`
|
||||||
<div class="panel pad">
|
<div class="layout-grid grid-main">
|
||||||
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle">后续再接真实发布记录</div></div></div>
|
<div class="side-stack">
|
||||||
<div class="list">
|
<div class="panel pad">
|
||||||
${completed.map((job) => `
|
<div class="panel-head"><div><h3>已保存复盘</h3><div class="panel-subtitle">当前项目的真实复盘记录</div></div><span class="tag blue">${escapeHtml(formatNumber(reviews.length))} 条</span></div>
|
||||||
<div class="review-card">
|
<div class="list">
|
||||||
<h4>${escapeHtml(job.title)}</h4>
|
${reviews.map((review) => `
|
||||||
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
|
<div class="review-card compact">
|
||||||
<div class="task-meta">
|
<h4>${escapeHtml(review.title)}</h4>
|
||||||
<span class="tag green">已完成</span>
|
<p>${escapeHtml(brief(review.highlights || review.next_actions || review.notes || "已保存复盘,待继续补充表现数据。", 92))}</p>
|
||||||
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
<div class="task-meta">
|
||||||
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
|
<span class="tag blue">${escapeHtml(review.platform || "douyin")}</span>
|
||||||
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
|
<span class="tag ${statusTone(review.verdict || "blue")}">${escapeHtml(review.verdict || "已记录")}</span>
|
||||||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
|
${review.publish_url ? `<a class="tag" href="${escapeHtml(review.publish_url)}" target="_blank" rel="noreferrer">打开链接</a>` : ""}
|
||||||
</div>
|
<span class="tag clickable-tag" data-action="open-review-edit" data-review-id="${escapeHtml(review.id)}">编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("") || `<div class="review-card"><h4>还没有复盘</h4><p>可以把最近完成任务直接写成一条复盘。</p></div>`}
|
||||||
</div>
|
</div>
|
||||||
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="side-stack">
|
||||||
|
<div class="panel pad">
|
||||||
|
<div class="panel-head"><div><h3>最近完成</h3><div class="panel-subtitle">从完成任务继续写复盘或进入下一步生产</div></div></div>
|
||||||
|
<div class="list">
|
||||||
|
${completed.map((job) => `
|
||||||
|
<div class="review-card compact">
|
||||||
|
<h4>${escapeHtml(job.title)}</h4>
|
||||||
|
<p>${escapeHtml(brief(job.style_summary || job.transcript_text || "已完成,待补复盘。", 84))}</p>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="tag green">已完成</span>
|
||||||
|
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
|
||||||
|
<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(job.id)}">写复盘</span>
|
||||||
|
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
|
||||||
|
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
|
||||||
|
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${renderLastJobDetailCard()}
|
${renderLastJobDetailCard()}
|
||||||
@@ -1709,6 +1781,31 @@ async function createProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPreferredModelAction() {
|
||||||
|
const models = getModelOptions();
|
||||||
|
const currentId = appState.me?.preferred_analysis_model_id
|
||||||
|
|| safeArray(appState.dashboard?.model_profiles).find((item) => item.is_default)?.id
|
||||||
|
|| models[0]?.value
|
||||||
|
|| "";
|
||||||
|
openActionModal({
|
||||||
|
title: "设置分析主模型",
|
||||||
|
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
|
||||||
|
submitLabel: "保存模型",
|
||||||
|
fields: [
|
||||||
|
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
|
||||||
|
],
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
if (!values.modelProfileId) throw new Error("请先选择一个模型");
|
||||||
|
await storyforgeFetch("/v2/me/preferences/analysis-model", {
|
||||||
|
method: "POST",
|
||||||
|
body: { model_profile_id: values.modelProfileId }
|
||||||
|
});
|
||||||
|
rememberAction("主模型已更新", "新的分析主模型已经保存。", "green");
|
||||||
|
await bootstrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function rememberAction(title, summary, tone = "blue", payload = null) {
|
function rememberAction(title, summary, tone = "blue", payload = null) {
|
||||||
appState.lastAction = {
|
appState.lastAction = {
|
||||||
title,
|
title,
|
||||||
@@ -1761,6 +1858,7 @@ function renderLastJobDetailCard() {
|
|||||||
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
|
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
|
||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
|
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
|
||||||
|
${detail.job.status === "completed" ? `<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(detail.job.id)}">写复盘</span>` : ""}
|
||||||
${canDeriveAiVideo(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(detail.job.id)}">做 AI 视频</span>` : ""}
|
${canDeriveAiVideo(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(detail.job.id)}">做 AI 视频</span>` : ""}
|
||||||
${canDeriveRealCut(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(detail.job.id)}">做实拍剪辑</span>` : ""}
|
${canDeriveRealCut(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(detail.job.id)}">做实拍剪辑</span>` : ""}
|
||||||
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(detail.job.id)}">看详情</span>
|
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(detail.job.id)}">看详情</span>
|
||||||
@@ -2464,6 +2562,89 @@ function openCreateRealCutAction(defaults = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReviewAction(defaults = {}) {
|
||||||
|
const project = requireSelectedProject();
|
||||||
|
const assistants = getAssistantOptions(project.id);
|
||||||
|
const sourceJob = defaults.sourceJob || null;
|
||||||
|
const existingReview = defaults.review || null;
|
||||||
|
const metrics = existingReview?.metrics || {};
|
||||||
|
openActionModal({
|
||||||
|
title: existingReview ? "编辑复盘" : "写复盘",
|
||||||
|
description: existingReview
|
||||||
|
? "补充表现数据、判断和下一步动作,持续迭代项目策略。"
|
||||||
|
: "把完成任务写成一条可追踪复盘,后续可按项目累计。",
|
||||||
|
submitLabel: existingReview ? "保存复盘" : "创建复盘",
|
||||||
|
fields: [
|
||||||
|
{ name: "title", label: "标题", value: existingReview?.title || defaults.title || sourceJob?.title || "", placeholder: "例如:创业口播 3 月 22 日复盘" },
|
||||||
|
{ name: "sourceJobId", label: "关联任务", type: "select", value: existingReview?.source_job_id || defaults.sourceJobId || sourceJob?.id || "", options: [{ value: "", label: "不关联任务" }, ...getCompletedJobOptions()] },
|
||||||
|
{ name: "assistantId", label: "负责 Agent", type: "select", value: existingReview?.assistant_id || getSelectedAssistant()?.id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] },
|
||||||
|
{ name: "platform", label: "平台", type: "select", value: existingReview?.platform || defaults.platform || "douyin", options: [
|
||||||
|
{ value: "douyin", label: "抖音" },
|
||||||
|
{ value: "xiaohongshu", label: "小红书" },
|
||||||
|
{ value: "bilibili", label: "哔哩哔哩" },
|
||||||
|
{ value: "youtube", label: "YouTube" },
|
||||||
|
{ value: "kuaishou", label: "快手" },
|
||||||
|
{ value: "wechat_video", label: "微信视频号" }
|
||||||
|
] },
|
||||||
|
{ name: "contentType", label: "内容类型", type: "select", value: existingReview?.content_type || "video", options: [
|
||||||
|
{ value: "video", label: "视频" },
|
||||||
|
{ value: "image_text", label: "图文" },
|
||||||
|
{ value: "live_clip", label: "直播切片" }
|
||||||
|
] },
|
||||||
|
{ name: "publishUrl", label: "发布链接", type: "url", value: existingReview?.publish_url || "", placeholder: "https://..." },
|
||||||
|
{ name: "publishedAt", label: "发布时间", value: existingReview?.published_at || "", placeholder: "2026-03-22T20:00:00+08:00" },
|
||||||
|
{ name: "playCount", label: "播放", type: "number", value: metrics.play_count || 0, min: 0 },
|
||||||
|
{ name: "likeCount", label: "点赞", type: "number", value: metrics.like_count || 0, min: 0 },
|
||||||
|
{ name: "commentCount", label: "评论", type: "number", value: metrics.comment_count || 0, min: 0 },
|
||||||
|
{ name: "shareCount", label: "分享", type: "number", value: metrics.share_count || 0, min: 0 },
|
||||||
|
{ name: "verdict", label: "结论", type: "select", value: existingReview?.verdict || "", options: [
|
||||||
|
{ value: "", label: "先不下结论" },
|
||||||
|
{ value: "worth_scaling", label: "值得放大" },
|
||||||
|
{ value: "needs_rework", label: "需要重做" },
|
||||||
|
{ value: "good_reference", label: "适合借鉴" },
|
||||||
|
{ value: "hold", label: "先观察" }
|
||||||
|
] },
|
||||||
|
{ name: "highlights", label: "亮点", type: "textarea", rows: 4, value: existingReview?.highlights || "", placeholder: "例如:开头 3 秒抓人、评论区问题很集中" },
|
||||||
|
{ name: "nextActions", label: "下一步", type: "textarea", rows: 4, value: existingReview?.next_actions || "", placeholder: "例如:保留结构,换一个细分人群再做一条" },
|
||||||
|
{ name: "notes", label: "备注", type: "textarea", rows: 4, value: existingReview?.notes || "", placeholder: "补充团队讨论、平台环境、发布时间段等信息" }
|
||||||
|
],
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
if (!values.title?.trim()) throw new Error("请填写复盘标题");
|
||||||
|
const payload = {
|
||||||
|
project_id: project.id,
|
||||||
|
source_job_id: values.sourceJobId || "",
|
||||||
|
assistant_id: values.assistantId || "",
|
||||||
|
title: values.title.trim(),
|
||||||
|
platform: values.platform || "douyin",
|
||||||
|
content_type: values.contentType || "video",
|
||||||
|
publish_url: values.publishUrl || "",
|
||||||
|
published_at: values.publishedAt || "",
|
||||||
|
metrics: {
|
||||||
|
play_count: Number(values.playCount || 0),
|
||||||
|
like_count: Number(values.likeCount || 0),
|
||||||
|
comment_count: Number(values.commentCount || 0),
|
||||||
|
share_count: Number(values.shareCount || 0)
|
||||||
|
},
|
||||||
|
verdict: values.verdict || "",
|
||||||
|
highlights: values.highlights || "",
|
||||||
|
next_actions: values.nextActions || "",
|
||||||
|
notes: values.notes || ""
|
||||||
|
};
|
||||||
|
const review = existingReview
|
||||||
|
? await storyforgeFetch(`/v2/reviews/${encodeURIComponent(existingReview.id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
: await storyforgeFetch("/v2/reviews", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
rememberAction(existingReview ? "复盘已更新" : "复盘已创建", `已保存「${review.title}」并回写到项目复盘。`, "green", review);
|
||||||
|
await bootstrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("click", async (event) => {
|
document.addEventListener("click", async (event) => {
|
||||||
const action = event.target.closest("[data-action]");
|
const action = event.target.closest("[data-action]");
|
||||||
if (action) {
|
if (action) {
|
||||||
@@ -2561,6 +2742,34 @@ document.addEventListener("click", async (event) => {
|
|||||||
openCreateRealCutAction();
|
openCreateRealCutAction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (name === "open-create-review") {
|
||||||
|
openReviewAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === "open-preferred-model") {
|
||||||
|
openPreferredModelAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === "open-review-from-job") {
|
||||||
|
const jobId = action.dataset.jobId || "";
|
||||||
|
const fromDashboard = safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === jobId) || null;
|
||||||
|
const fromDetail = appState.lastJobDetail?.job?.id === jobId ? appState.lastJobDetail.job : null;
|
||||||
|
openReviewAction({
|
||||||
|
sourceJobId: jobId,
|
||||||
|
sourceJob: fromDetail || fromDashboard,
|
||||||
|
title: (fromDetail || fromDashboard)?.title || ""
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === "open-review-edit") {
|
||||||
|
const review = getReviewById(action.dataset.reviewId || "");
|
||||||
|
if (!review) {
|
||||||
|
alert("复盘记录不存在,请先刷新页面");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openReviewAction({ review });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (name === "open-similar-search") {
|
if (name === "open-similar-search") {
|
||||||
openSimilaritySearchAction();
|
openSimilaritySearchAction();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user