mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 12:59:24 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40c246a1f | ||
|
|
b46ee08c40 |
@@ -30,6 +30,7 @@ import type {
|
||||
CreatePersonalAccessTokenRequest,
|
||||
CreatePersonalAccessTokenResponse,
|
||||
RuntimeUsage,
|
||||
IssueUsageSummary,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
@@ -415,6 +416,10 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
||||
}
|
||||
|
||||
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
|
||||
return this.fetch(`/api/issues/${issueId}/usage`);
|
||||
}
|
||||
|
||||
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const issueKeys = {
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
@@ -79,3 +80,10 @@ export function issueSubscribersOptions(issueId: string) {
|
||||
queryFn: () => api.listIssueSubscribers(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueUsageOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.usage(issueId),
|
||||
queryFn: () => api.getIssueUsage(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +138,14 @@ export interface RuntimePing {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IssueUsageSummary {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_read_tokens: number;
|
||||
total_cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
export interface RuntimeUsage {
|
||||
runtime_id: string;
|
||||
date: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
RuntimePingStatus,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
|
||||
@@ -70,7 +70,7 @@ import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueListOptions, issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries";
|
||||
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useUpdateIssue, useDeleteIssue } from "@multica/core/issues/mutations";
|
||||
import { useIssueTimeline } from "../hooks/use-issue-timeline";
|
||||
@@ -193,6 +193,16 @@ function formatActivity(
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -285,6 +295,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
// Token usage
|
||||
const { data: usage } = useQuery(issueUsageOptions(id));
|
||||
|
||||
// Sub-issue queries
|
||||
const parentIssueId = issue?.parent_issue_id;
|
||||
const { data: parentIssue = null } = useQuery({
|
||||
@@ -1271,6 +1284,34 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Token usage */}
|
||||
{usage && usage.task_count > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rotate-90" />
|
||||
Token usage
|
||||
</div>
|
||||
<div className="space-y-0.5 pl-2">
|
||||
<PropRow label="Input">
|
||||
<span className="text-muted-foreground">{formatTokenCount(usage.total_input_tokens)}</span>
|
||||
</PropRow>
|
||||
<PropRow label="Output">
|
||||
<span className="text-muted-foreground">{formatTokenCount(usage.total_output_tokens)}</span>
|
||||
</PropRow>
|
||||
{(usage.total_cache_read_tokens > 0 || usage.total_cache_write_tokens > 0) && (
|
||||
<PropRow label="Cache">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokenCount(usage.total_cache_read_tokens)} read / {formatTokenCount(usage.total_cache_write_tokens)} write
|
||||
</span>
|
||||
</PropRow>
|
||||
)}
|
||||
<PropRow label="Runs">
|
||||
<span className="text-muted-foreground">{usage.task_count}</span>
|
||||
</PropRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -175,6 +175,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Get("/active-task", h.GetActiveTaskForIssue)
|
||||
r.Post("/tasks/{taskId}/cancel", h.CancelTask)
|
||||
r.Get("/task-runs", h.ListTasksByIssue)
|
||||
r.Get("/usage", h.GetIssueUsage)
|
||||
r.Post("/reactions", h.AddIssueReaction)
|
||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||
r.Get("/attachments", h.ListAttachments)
|
||||
|
||||
@@ -627,3 +627,22 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetIssueUsage returns aggregated token usage for all tasks belonging to an issue.
|
||||
func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
|
||||
row, err := h.Queries.GetIssueUsageSummary(r.Context(), parseUUID(issueID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get issue usage")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"total_input_tokens": row.TotalInputTokens,
|
||||
"total_output_tokens": row.TotalOutputTokens,
|
||||
"total_cache_read_tokens": row.TotalCacheReadTokens,
|
||||
"total_cache_write_tokens": row.TotalCacheWriteTokens,
|
||||
"task_count": row.TaskCount,
|
||||
})
|
||||
}
|
||||
|
||||
1
server/migrations/035_task_queue_issue_id_index.down.sql
Normal file
1
server/migrations/035_task_queue_issue_id_index.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_agent_task_queue_issue_id;
|
||||
5
server/migrations/035_task_queue_issue_id_index.up.sql
Normal file
5
server/migrations/035_task_queue_issue_id_index.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add a general index on agent_task_queue(issue_id) to support aggregation
|
||||
-- queries like GetIssueUsageSummary that scan across all task statuses.
|
||||
-- (Migration 022 only covers queued/dispatched rows via a partial index.)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agent_task_queue_issue_id
|
||||
ON agent_task_queue (issue_id);
|
||||
@@ -11,6 +11,39 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getIssueUsageSummary = `-- name: GetIssueUsageSummary :one
|
||||
SELECT
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
|
||||
COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
||||
COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
|
||||
COUNT(DISTINCT tu.task_id)::int AS task_count
|
||||
FROM task_usage tu
|
||||
JOIN agent_task_queue atq ON atq.id = tu.task_id
|
||||
WHERE atq.issue_id = $1
|
||||
`
|
||||
|
||||
type GetIssueUsageSummaryRow struct {
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||
TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"`
|
||||
TaskCount int32 `json:"task_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) (GetIssueUsageSummaryRow, error) {
|
||||
row := q.db.QueryRow(ctx, getIssueUsageSummary, issueID)
|
||||
var i GetIssueUsageSummaryRow
|
||||
err := row.Scan(
|
||||
&i.TotalInputTokens,
|
||||
&i.TotalOutputTokens,
|
||||
&i.TotalCacheReadTokens,
|
||||
&i.TotalCacheWriteTokens,
|
||||
&i.TaskCount,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTaskUsage = `-- name: GetTaskUsage :many
|
||||
SELECT id, task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at FROM task_usage
|
||||
WHERE task_id = $1
|
||||
|
||||
@@ -45,3 +45,14 @@ WHERE a.workspace_id = $1
|
||||
AND atq.created_at >= @since::timestamptz
|
||||
GROUP BY tu.model
|
||||
ORDER BY (SUM(tu.input_tokens) + SUM(tu.output_tokens)) DESC;
|
||||
|
||||
-- name: GetIssueUsageSummary :one
|
||||
SELECT
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
|
||||
COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
||||
COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
|
||||
COUNT(DISTINCT tu.task_id)::int AS task_count
|
||||
FROM task_usage tu
|
||||
JOIN agent_task_queue atq ON atq.id = tu.task_id
|
||||
WHERE atq.issue_id = $1;
|
||||
|
||||
Reference in New Issue
Block a user