Compare commits

..

3 Commits

Author SHA1 Message Date
liangshuo-1
3bfb80951d chore(release): v1.0.45 (#1207) 2026-06-01 22:08:11 +08:00
hugang-lark
639259fbfd fix: add vc-domain-boundaries and enrich vc +notes (#1172) 2026-06-01 19:03:55 +08:00
JackZhao10086
0bdd7de807 refactor(auth): update login hint and split-flow docs (#1201) 2026-06-01 16:47:18 +08:00
29 changed files with 791 additions and 407 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.45] - 2026-06-01
### Features
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
- **platform**: Support multiple policy rules per plugin (#1182)
### Bug Fixes
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
- **whiteboard**: Fix whiteboard skill (#1180)
### Refactor
- **auth**: Update login hint and split-flow docs (#1201)
## [v1.0.44] - 2026-05-29
### Features
@@ -948,6 +964,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42

View File

@@ -279,7 +279,13 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
"**Display order:** Output the URL first, then place the QR code image below the URL." +
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)

View File

@@ -1042,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
"After the user confirms authorization in a later step",
"lark-cli auth login --device-code device-code",
"come back and notify",
"YOU must execute",
"lark-cli auth login --device-code <device_code>",
"Do NOT cache",
"lark-cli auth login --no-wait --json",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.44",
"version": "1.0.45",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -38,6 +38,8 @@ import (
var (
scopesMeetingIDs = []string{
"vc:meeting.meetingevent:read",
"vc:note:read",
"vc:record:readonly",
}
scopesMinuteTokens = []string{
"minutes:minutes:readonly",
@@ -48,6 +50,7 @@ var (
"calendar:calendar:read",
"calendar:calendar.event:read",
"vc:meeting.meetingevent:read",
"vc:record:readonly",
}
)
@@ -59,6 +62,37 @@ const (
const logPrefix = "[vc +notes]"
const (
minutesNoReadPermissionCode = 2091005
// recording API specific error codes (used to surface meeting minute_token state).
recordingNotFoundCode = 121004 // 该会议没有妙记文件
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
// note detail API specific error code.
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
)
func minutesReadError(err error, minuteToken string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode {
return err
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_read_permission",
Code: minutesNoReadPermissionCode,
Message: fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken),
Hint: "Ask the minute owner for minute file read permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}
// validMinuteToken matches the server's minute-token format and blocks any
// user-supplied token from reaching filesystem paths unsanitized.
var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -196,7 +230,10 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
for _, meetingID := range relInfo.MeetingIDs {
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
noteResult := fetchNoteByMeetingID(ctx, runtime, meetingID)
if noteResult["error"] == nil {
// success means note detail was retrieved, regardless of whether the
// recording API (minute_token) call succeeded — minute_token failures
// surface as part of the merged `error` string for downstream visibility.
if _, ok := noteResult["note_doc_token"].(string); ok {
for k, v := range noteResult {
result[k] = v
}
@@ -246,7 +283,51 @@ func asStringSlice(v any) []string {
return ss
}
// fetchNoteByMeetingID queries notes via meeting_id.
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
// the associated minute_token (parsed from the recording URL) and an
// optional human-friendly error message. On success token is non-empty and
// errMsg is empty; on failure token is empty and errMsg describes the cause:
// - 121004: meeting has no minute file
// - 121005: caller has no permission for the meeting recording
// - 124002: recording / minute file is still being generated
//
// Other failures fall back to the raw API error description so Agents can
// still parse the underlying cause.
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
switch exitErr.Detail.Code {
case recordingNotFoundCode:
return "", "no minute file for this meeting"
case recordingNoPermissionCode:
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
case recordingGeneratingCode:
return "", "minute file is still being generated; please retry later"
}
}
return "", fmt.Sprintf("failed to query recording: %v", err)
}
recording, _ := data["recording"].(map[string]any)
if recording == nil {
return "", "no recording available for this meeting"
}
recordingURL, _ := recording["url"].(string)
if t := extractMinuteToken(recordingURL); t != "" {
return t, ""
}
return "", "no minute_token found in recording URL"
}
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
// the meeting's minute_token via the recording API. The two paths are queried
// independently; their failures are merged into a single `error` field
// (semicolon-separated) so Agents always see all causes at once. The
// `minute_token` field is only populated on success.
func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
larkcore.QueryParams{"with_participants": []string{"false"}, "query_mode": []string{"0"}}, nil)
@@ -259,16 +340,60 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
return map[string]any{"meeting_id": meetingID, "error": "meeting not found"}
}
noteID, _ := meeting["note_id"].(string)
if noteID == "" {
return map[string]any{"meeting_id": meetingID, "error": "no notes available for this meeting"}
// Always attempt to query the meeting's minute_token via the recording API,
// regardless of whether the meeting has a note_id, so callers always see
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
var result map[string]any
var noteErr string
if noteID, _ := meeting["note_id"].(string); noteID != "" {
result = fetchNoteDetail(ctx, runtime, noteID)
if msg, _ := result["error"].(string); msg != "" {
noteErr = msg
delete(result, "error")
}
} else {
result = map[string]any{}
noteErr = "no notes available for this meeting"
}
result := fetchNoteDetail(ctx, runtime, noteID)
result["meeting_id"] = meetingID
if minuteToken != "" {
result["minute_token"] = minuteToken
}
if combined := joinErrors(noteErr, minuteErr); combined != "" {
result["error"] = combined
}
return result
}
// joinErrors merges multiple non-empty error messages with "; " so Agents can
// see all causes at once when both note and minute paths fail.
func joinErrors(msgs ...string) string {
parts := make([]string, 0, len(msgs))
for _, m := range msgs {
if m != "" {
parts = append(parts, m)
}
}
return strings.Join(parts, "; ")
}
// hasNotesPayload reports whether a result map carries any usable note or
// minute payload, irrespective of partial failures surfaced via `error`.
func hasNotesPayload(m map[string]any) bool {
if m == nil {
return false
}
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
if v, ok := m[k]; ok && v != nil && v != "" {
return true
}
}
return false
}
// fetchNoteByMinuteToken queries notes via minute_token.
// Fetches both note detail (doc tokens) and AI artifacts (summary/todos/chapters inline +
// transcript to file) independently, merging into a single result map for Agent consumption.
@@ -277,7 +402,13 @@ func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext,
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
return map[string]any{"minute_token": minuteToken, "error": fmt.Sprintf("failed to query minutes: %v", err)}
err = minutesReadError(err, minuteToken)
result := map[string]any{"minute_token": minuteToken, "error": err.Error()}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" {
result["hint"] = exitErr.Detail.Hint
}
return result
}
minute, _ := data["minute"].(map[string]any)
@@ -472,6 +603,10 @@ func extractDocTokens(refs []any) []string {
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == noteNoPermissionCode {
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", exitErr.Detail.Code)}
}
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
}
@@ -568,8 +703,9 @@ var VCNotes = common.Shortcut{
return common.NewDryRunAPI().
GET("/open-apis/vc/v1/meetings/{meeting_id}").
GET("/open-apis/vc/v1/notes/{note_id}").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("meeting_ids", common.SplitCSV(ids)).
Set("steps", "meeting.get → note_id → note detail API")
Set("steps", "meeting.get → note_id → note detail API + recording API → minute_token")
}
if tokens := runtime.Str("minute-tokens"); tokens != "" {
return common.NewDryRunAPI().
@@ -586,8 +722,9 @@ var VCNotes = common.Shortcut{
POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info").
GET("/open-apis/vc/v1/meetings/{meeting_id}").
GET("/open-apis/vc/v1/notes/{note_id}").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("calendar_event_ids", common.SplitCSV(ids)).
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API")
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API + recording API → minute_token")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
@@ -641,11 +778,13 @@ var VCNotes = common.Shortcut{
}
}
// count results
// count results: a result counts as "successful" when it carries any
// note/minute payload, even if the merged `error` field surfaces a
// partial failure (e.g. note ok but minute_token lookup failed).
successCount := 0
for _, r := range results {
m, _ := r.(map[string]any)
if m["error"] == nil {
if hasNotesPayload(m) {
successCount++
}
}

View File

@@ -728,3 +728,352 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
t.Errorf("minutes/ should not be created when --output-dir is explicit")
}
}
// ---------------------------------------------------------------------------
// Tests for joinErrors / hasNotesPayload (pure helpers)
// ---------------------------------------------------------------------------
func TestJoinErrors(t *testing.T) {
tests := []struct {
name string
in []string
want string
}{
{"all empty", []string{"", "", ""}, ""},
{"single", []string{"only"}, "only"},
{"two non-empty", []string{"a", "b"}, "a; b"},
{"skip empties", []string{"", "a", "", "b", ""}, "a; b"},
{"three", []string{"x", "y", "z"}, "x; y; z"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := joinErrors(tt.in...); got != tt.want {
t.Errorf("joinErrors(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestHasNotesPayload(t *testing.T) {
tests := []struct {
name string
in map[string]any
want bool
}{
{"nil", nil, false},
{"empty", map[string]any{}, false},
{"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false},
{"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false},
{"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true},
{"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true},
{"has minute_token", map[string]any{"minute_token": "obc"}, true},
{"has meeting_notes", map[string]any{"meeting_notes": []string{"d1"}}, true},
{"has shared_doc_tokens", map[string]any{"shared_doc_tokens": []string{"s1"}}, true},
{"has artifacts", map[string]any{"artifacts": map[string]any{"summary": "s"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasNotesPayload(tt.in); got != tt.want {
t.Errorf("hasNotesPayload(%v) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// Tests for fetchMeetingMinuteToken — recording API → minute_token mapping
// ---------------------------------------------------------------------------
// recordingStub is a small helper for shaping `/v1/meetings/{id}/recording` responses.
func recordingStub(meetingID string, body map[string]any) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/" + meetingID + "/recording",
Body: body,
}
}
func recordingErrStub(meetingID string, code int, msg string) *httpmock.Stub {
return recordingStub(meetingID, map[string]any{"code": code, "msg": msg})
}
func recordingOKStub(meetingID, url string) *httpmock.Stub {
return recordingStub(meetingID, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"recording": map[string]any{"url": url},
},
})
}
func TestFetchMeetingMinuteToken_Success(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
if token != "obctoken_ok" {
t.Errorf("token = %q, want obctoken_ok", token)
}
if msg != "" {
t.Errorf("errMsg = %q, want empty", msg)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
meetingID string
code int
wantMsg string
}{
{"121004 not found", "m_121004", 121004, "no minute file for this meeting"},
{"121005 no permission", "m_121005", 121005, "no permission to access this meeting's minute"},
{"124002 generating", "m_124002", 124002, "minute file is still being generated"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
if token != "" {
t.Errorf("token = %q, want empty on error", token)
}
if !strings.Contains(msg, tt.wantMsg) {
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(recordingErrStub("m_other", 99999, "weird"))
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if !strings.Contains(msg, "failed to query recording") {
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(recordingStub("m_norec", map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{},
}))
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if !strings.Contains(msg, "no recording available") {
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if !strings.Contains(msg, "no minute_token found") {
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Integration: fetchNoteByMeetingID — note + minute_token combined behavior
// ---------------------------------------------------------------------------
// extractFirstNote runs +notes via --meeting-ids and returns the single result map.
func extractFirstNote(t *testing.T, stdout *bytes.Buffer) map[string]any {
t.Helper()
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v\n%s", err, stdout.String())
}
data, _ := resp["data"].(map[string]any)
notes, _ := data["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("expected 1 note, got %d (%v)", len(notes), notes)
}
note, _ := notes[0].(map[string]any)
return note
}
// assertNoteError verifies the result map's `error` field contains every
// substring in wantSubstrs (order-independent). Pass an empty slice to assert
// the field is absent. Centralized here so tests don't have to repeat the same
// "for each substring, Contains + Errorf" pattern.
func assertNoteError(t *testing.T, note map[string]any, wantSubstrs ...string) {
t.Helper()
errMsg, _ := note["error"].(string)
if len(wantSubstrs) == 0 {
if e, has := note["error"]; has {
t.Errorf("error should be absent, got %v", e)
}
return
}
for _, sub := range wantSubstrs {
if !strings.Contains(errMsg, sub) {
t.Errorf("error %q missing substring %q", errMsg, sub)
}
}
}
// assertNoteFieldAbsent fails the test if any of the named fields is present.
func assertNoteFieldAbsent(t *testing.T, note map[string]any, fields ...string) {
t.Helper()
for _, f := range fields {
if v, has := note[f]; has {
t.Errorf("%s should be absent, got %v", f, v)
}
}
}
func TestNotes_MeetingPath_NoteAndMinuteBothOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_both", "note_both"))
reg.Register(noteDetailStub("note_both"))
reg.Register(recordingOKStub("m_both", "https://meetings.feishu.cn/minutes/obc_both"))
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_both", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
note := extractFirstNote(t, stdout)
if got := note["note_doc_token"]; got != "doc_main" {
t.Errorf("note_doc_token = %v, want doc_main", got)
}
if got := note["minute_token"]; got != "obc_both" {
t.Errorf("minute_token = %v, want obc_both", got)
}
assertNoteError(t, note)
}
func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_minfail", "note_minfail"))
reg.Register(noteDetailStub("note_minfail"))
reg.Register(recordingErrStub("m_minfail", 121005, "no permission"))
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_minfail", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
note := extractFirstNote(t, stdout)
if got := note["note_doc_token"]; got != "doc_main" {
t.Errorf("note_doc_token = %v, want doc_main", got)
}
assertNoteFieldAbsent(t, note, "minute_token")
assertNoteError(t, note, "no permission to access this meeting's minute")
}
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// note_id missing on the meeting object → no notes, but minute_token present
reg.Register(meetingGetStub("m_nonote", ""))
reg.Register(recordingOKStub("m_nonote", "https://meetings.feishu.cn/minutes/obc_nonote"))
if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
note := extractFirstNote(t, stdout)
if got := note["minute_token"]; got != "obc_nonote" {
t.Errorf("minute_token = %v, want obc_nonote", got)
}
assertNoteError(t, note, "no notes available for this meeting")
}
func TestNotes_MeetingPath_BothFail_ErrorJoinedWithSemicolon(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// no note_id → "no notes available..."; recording 121004 → "no minute file..."
reg.Register(meetingGetStub("m_bothfail", ""))
reg.Register(recordingErrStub("m_bothfail", 121004, "data not found"))
// Two-path failure with no payload should make the batch return ErrAPI.
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_bothfail", "--as", "user"}, f, stdout)
if err == nil {
t.Fatalf("expected batch failure error, got nil")
}
note := extractFirstNote(t, stdout)
assertNoteFieldAbsent(t, note, "minute_token")
assertNoteError(t, note,
"no notes available for this meeting",
"no minute file for this meeting",
"; ", // causes joined with semicolon
)
}
// noteDetailErrStub returns a stub that emits an error response from
// /open-apis/vc/v1/notes/{note_id}.
func noteDetailErrStub(noteID string, code int, msg string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/" + noteID,
Body: map[string]any{"code": code, "msg": msg},
}
}
func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// note 接口返回 121005 → 阅读权限不足;同时 recording 也返回 121005
// 用以验证两路错误都会被合并到顶层 error 字段(用 "; " 拼接)。
reg.Register(meetingGetStub("m_noteperm", "note_noperm"))
reg.Register(noteDetailErrStub("note_noperm", 121005, "no permission"))
reg.Register(recordingErrStub("m_noteperm", 121005, "no permission"))
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_noteperm", "--as", "user"}, f, stdout)
if err == nil {
t.Fatalf("expected batch failure error, got nil")
}
note := extractFirstNote(t, stdout)
assertNoteFieldAbsent(t, note, "note_doc_token", "minute_token")
assertNoteError(t, note,
"[121005]",
"no read permission for this meeting note",
"; ", // note + minute causes joined with semicolon
)
}

View File

@@ -172,7 +172,7 @@ func meetingSearchDescription(item map[string]interface{}) string {
var VCSearch = common.Shortcut{
Service: "vc",
Command: "+search",
Description: "Search meeting records (requires at least one filter)",
Description: "Search meeting records by keyword, time range, participant, organizer, or meeting room (requires at least one filter)",
Risk: "read",
Scopes: []string{"vc:meeting.search:read"},
AuthTypes: []string{"user"},

View File

@@ -5,7 +5,6 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -24,7 +23,6 @@ import (
const (
WhiteboardQueryAsImage = "image"
WhiteboardQueryAsSvg = "svg"
WhiteboardQueryAsCode = "code"
WhiteboardQueryAsRaw = "raw"
)
@@ -67,8 +65,8 @@ var WhiteboardQuery = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
@@ -89,8 +87,8 @@ var WhiteboardQuery = common.Shortcut{
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return common.FlagErrorf("--output_as flag must be one of: image | svg | code | raw")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
}
return nil
},
@@ -110,13 +108,8 @@ var WhiteboardQuery = common.Shortcut{
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -127,86 +120,17 @@ var WhiteboardQuery = common.Shortcut{
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return output.ErrValidation("--output_as flag must be one of: image | svg | code | raw")
return output.ErrValidation("--as flag must be one of: image | code | raw")
}
},
}
type exportReq struct {
ExportType string `json:"export_type"`
}
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return output.ErrNetwork(fmt.Sprintf("export whiteboard svg failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err != nil {
return output.Errorf(output.ExitInternal, "parsing", "parse export response failed: %v", err)
}
if exportData.Code != 0 {
return output.ErrAPI(exportData.Code, "export whiteboard svg failed", exportData.Msg)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return output.Errorf(output.ExitInternal, "parsing", "decode svg base64 failed: %v", err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
@@ -435,8 +359,6 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":

View File

@@ -6,7 +6,6 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
@@ -109,14 +108,6 @@ func TestWhiteboardQuery_Validate(t *testing.T) {
},
wantErr: false,
},
{
name: "valid: svg without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
},
wantErr: false,
},
{
name: "invalid: image without output",
flags: map[string]string{
@@ -197,15 +188,6 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
},
{
name: "dry run svg",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
},
wantMethod: "POST",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/export",
},
}
for _, tt := range tests {
@@ -729,138 +711,6 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
}
}
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="red"/></svg>`
encoded := base64.StdEncoding.EncodeToString([]byte(svgContent))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": encoded,
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(stdout.String(), "svg_content") {
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `width=\"100\"`) && !strings.Contains(stdout.String(), `width="100"`) {
t.Fatalf("stdout missing svg attributes: %s", stdout.String())
}
}
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><circle r="50"/></svg>`
encoded := base64.StdEncoding.EncodeToString([]byte(svgContent))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": encoded,
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("output.svg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != svgContent {
t.Fatalf("svg file content = %q, want %q", string(data), svgContent)
}
}
func TestExportWhiteboardSvg_APIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-err/export",
Status: 500,
Body: map[string]interface{}{
"code": 99999,
"msg": "internal error",
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-err", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected API error, but got none")
}
}
func TestExportWhiteboardSvg_BusinessError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-biz-err/export",
Body: map[string]interface{}{
"code": 10002,
"msg": "whiteboard not found",
"data": map[string]interface{}{
"content": "",
"mime_type": "",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-biz-err", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected business error, but got none")
}
}
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-bad-b64/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": "!!!not-valid-base64!!!",
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-bad-b64", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected base64 decode error, but got none")
}
}
// newTestRuntime creates a RuntimeContext with string flags for testing.
func newTestRuntime(flags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}

View File

@@ -12,6 +12,12 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 核心概念
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
@@ -134,6 +140,8 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
- `get` — 获取妙记信息
> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。
## 权限表
| 方法 | 所需 scope |

View File

@@ -81,6 +81,29 @@ lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
lark-cli auth login --device-code <device_code>
```
**Split-Flow 完整步骤**
**第一步:发起授权(当前轮)**
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`
2. 从 JSON 输出中提取 `verification_url``device_code`
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
4. 将 URL 和二维码展示给用户(先 URL后二维码
5. **结束本轮对话前,必须明确告知用户**"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
**第二步:完成授权(后续轮)**
1. 等待用户回复"已完成授权"
2. **由你AI agent亲自执行**`lark-cli auth login --device-code <device_code>`
3. 此命令会轮询授权状态并完成登录
4. 如果返回授权成功,流程结束
**关键规则**
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
- **禁止缓存 `verification_url``device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
## 更新检查
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。

View File

@@ -12,11 +12,17 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 核心概念
- **视频会议Meeting**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。
- **视频会议Meeting**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **纪要文档MainDoc**AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`
- **用户会议纪要MeetingNotes**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
- **逐字稿VerbatimDoc**:会议的逐句文字记录,包含说话人和时间戳。
@@ -29,8 +35,23 @@ metadata:
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。
### 2. 整理会议纪要
1. 整理纪要文档时默认给出纪要文档和逐字稿链接即可,无需读取纪要文档或逐字稿内容。
2. 用户明确需要获取纪要文档中的总结、待办、章节产物时,再读取文档获取具体内容
> ⚠️ 在选择读取哪个产物前,请先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「两条链路的独立性」章节
**⚠️ 产物选择决策 — 根据用户意图严格区分:**
| 用户意图 | 必须读取的产物 | 禁止 |
|---------|-------------|------|
| **提炼/总结/重新总结/整理会议内容/回顾会议** | 逐字稿(`verbatim_doc_token`或妙记文字记录Transcript基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出|
| **查看待办/章节** | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
| **查看纪要链接/文档地址** | 仅返回文档链接,无需读取内容 | — |
| **直接看 AI 总结结果** | AI 纪要(`note_doc_token` | — |
| **谁说了什么/完整发言记录** | 逐字稿(`verbatim_doc_token` | — |
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。AI 纪要可作为补充参考,但不能作为唯一信息源。
1. 整理纪要文档时默认给出纪要文档、逐字稿、妙记链接即可,无需读取纪要文档或逐字稿内容。
2. 用户明确需要获取总结、待办、章节产物时,再读取文档获取具体内容。
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图AI 生成的总结可视化),应同时下载展示给用户:
```bash
# 1. 读取纪要内容
@@ -43,7 +64,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
> **纪要相关文档 — 根据用户意图选择:**
> - `note_doc_token` → **AI 智能纪要**AI 总结 + 待办 + 章节
> - `note_doc_token` → **AI 智能纪要**AI 总结 + 待办)
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回)
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
@@ -119,7 +140,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids) |
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes and minutes (via meeting-ids, minute-tokens, or calendar-event-ids) |
| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids |
- 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。
@@ -158,9 +179,9 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
| 方法 | 所需 scope |
|------|-----------|
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read``vc:note:read` |
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
| `+notes --minute-tokens` | `vc:note:read``minutes:minutes:readonly``minutes:minutes.artifacts:read``minutes:minutes.transcript:export` |
| `+notes --calendar-event-ids` | `calendar:calendar:read``calendar:calendar.event:read``vc:meeting.meetingevent:read``vc:note:read` |
| `+notes --calendar-event-ids` | `calendar:calendar:read``calendar:calendar.event:read``vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
| `+recording --meeting-ids` | `vc:record:readonly` |
| `+recording --calendar-event-ids` | `vc:record:readonly``calendar:calendar:read``calendar:calendar.event:read` |
| `+search` | `vc:meeting.search:read` |

View File

@@ -63,9 +63,9 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| 输入 | 所需权限 |
|------|---------|
| `--meeting-ids` | `vc:meeting.meetingevent:read``vc:note:read` |
| `--meeting-ids` | `vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
| `--minute-tokens` | `vc:note:read``minutes:minutes:readonly``minutes:minutes.artifacts:read``minutes:minutes.transcript:export` |
| `--calendar-event-ids` | `calendar:calendar:read``calendar:calendar.event:read``vc:meeting.meetingevent:read``vc:note:read` |
| `--calendar-event-ids` | `calendar:calendar:read``calendar:calendar.event:read``vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
## 输出结果
@@ -75,6 +75,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| 字段 | 说明 |
|------|------|
| `meeting_id` | 会议 ID`--meeting-ids` / `--calendar-event-ids` 路径) |
| `minute_token` | **会议对应的妙记 Token**`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)|
| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 |
| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) |
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 |
@@ -83,6 +85,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| `create_time` | 创建时间(格式化) |
> **选择哪个 token** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。
>
> 📌 不确定该返回哪个 token参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。
### minute-tokens 路径的 AI 产物

View File

@@ -0,0 +1,154 @@
# Calendar/VC/Doc 跨领域关联关系、领域知识和职责边界说明
本文档说明飞书日历Calendar、视频会议VC、云文档Doc三个域之间的关联关系帮助理解跨域数据流转和产物依赖。
## Calendar 域
- **lark-calendar skill** 负责日历与日程管理,包括创建、查询、修改、删除日程等操作。
- **日程与会议的关系**:日程可以用于提前预约会议,确定会议时间、参与人、会议室、会议主题等信息。日程上可以关联飞书/Lark 视频会议。
- **并非所有会议都通过日程发起**:即时会议不经过日程预约,直接创建。因此,仅查询日程数据无法覆盖所有会议,搜索历史会议应优先使用 `vc +search`
- **日程上的用户会议纪要**用户可以在日程上绑定自己的会议纪要文档MeetingNotes用于手动记录会议相关信息。该文档与 AI 生成的智能纪要(`note_doc_token`)是不同的文档,相互独立。
> **路由规则**:查询过去已结束的会议 → `lark-vc`;查询未来日程/待开的会 → `lark-calendar`;查询"今天有哪些会议" → 两者结合(`vc +search` 查已结束 + `calendar` 查未开始)。
## VC 域
- **lark-vc skill** 负责视频会议管理,包括搜索历史会议、查询会议产物(智能纪要、逐字稿、妙记等)、查询参会人快照等操作。
- **会议类型**:会议可以是日程会议(由日程发起,有对应的 `calendar_event_id`),也可以是即时会议等其他类型。
### 会议产物
会议产物取决于会中开启的功能,分为两条独立链路:
#### 链路一开启「AI 总结」
会中开启「AI 总结」功能后,产生以下产物:
| 产物 | Token 字段 | 本质 | 说明 |
|------|-----------|------|------|
| 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 |
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) |
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
此外,还存在**用户会议纪要MeetingNotes**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
#### 链路二:开启「录制」
会中开启「录制」功能后,产生**妙记产物**`minute_token`)。注意:妙记不一定是会中产生的,用户上传音视频文件或录音也会产生妙记。妙记本身包含以下子产物:
| 子产物 | 说明 |
|--------|------|
| Summary总结 | 对整场会议的智能总结 |
| Todo待办 | 会议中识别出的待处理任务列表 |
| Chapter章节 | 按讨论话题划分的核心内容摘要 |
| Transcript文字记录 | 整场会议最原始的逐人发言记录 |
#### 两条链路的独立性
- 智能纪要AI 总结链路)和妙记(录制链路)**相互独立、互不影响**。
- 一场会议可能同时拥有两类产物,也可能只有其中一类,也可能都没有。
- 当两者都存在时Summary/Todo 内容可能重叠,应根据用户意图选择优先读取哪个。
> **产物选择决策**
> - **AI 产物 vs 原始记录**:智能总结、待办、章节都属于 AI 分析产物,可能只包含最终结论和关键信息。
> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版不是独立提炼。AI 纪要可作为补充参考,但不能作为内容总结的唯一信息源。
> - **用户要求查看待办或章节时** → **应参考 AI 产物的待办和章节**,因为 AI 产物的待办更友好(包含提出人和负责人),章节按话题划分更结构化。
> - **用户只想直接看 AI 总结结果** → 使用 AI 产物的总结。
> - **链路优先级**:如果用户没有明确偏好,对于重复的内容(如智能总结、待办),**优先查询智能纪要Note不存在时再降级到妙记Minutes**。
#### 逐字稿与文字记录的格式
智能纪要的逐字稿(`verbatim_doc_token`和妙记的文字记录Transcript都记录了用户原始对话内容格式一致
```
发言人名称 相对时间戳
<发言内容>
```
示例:
```
张三 00:00:00.195
我们接下来讨论一下项目进度。
```
- 第一行为发言人信息,包含用户名称和发言的相对时间(从会议开始计算的偏移量)。
- 后续行为该发言人的发言内容,直到下一个发言人标记出现。
### 会议总结和分析流程
#### Step 1: 定位会议
根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。
```bash
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
```
详细用法请阅读 [`lark-vc-search.md`](lark-vc-search.md)。
#### Step 2: 根据 meeting_id 查询产物
##### 获取会议产物
```bash
lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
```
可获取会议的所有产物信息,包括:
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
- 妙记 Token`minute_token`)— 如存在录制产物则返回
详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
如果返回了 `minute_token`,可通过以下命令获取妙记的详细信息(总结、待办、章节、文字记录):
```bash
lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
```
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
#### Step 3: Doc 域拉取文档内容
智能纪要和逐字稿都是飞书文档,需使用 `docs +fetch` 读取正文内容:
```bash
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。
#### Step 4: 判断用户需要的产物内容
- 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取
- 如果两种产物都不存在或没有权限,需如实告知用户
## Doc 域
- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。
- **会议产物的文档本质**:智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API`docs +fetch`)查询其内容和元信息。
- **文档元信息查询**获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`
## 三域关联总览
```
Calendar (日程) ──── 发起预约 ────► VC (会议)
┌──────────────────┤
│ │
AI 总结链路 录制链路
│ │
▼ ▼
智能纪要 (Doc) 妙记 (Minutes)
逐字稿 (Doc) ├── Summary
共享文档 (Doc) ├── Todo
用户纪要 (Doc) ├── Chapter
└── Transcript
```
- Calendar 提供会议预约入口,但并非所有会议都来自日程。
- VC 是会议数据的中心,管理会议记录和产物关联。
- Doc 是会议产物的载体,智能纪要和逐字稿都以飞书文档形式沉淀,需通过 Doc 域 API 读取。

View File

@@ -13,7 +13,7 @@ metadata:
> [!IMPORTANT]
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@0.1.1-beta -v`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
@@ -24,7 +24,6 @@ metadata:
| 用户需求 | 行动 |
|---|---|
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 导出画板并编辑后回写(有损) | [`routes/svg-edit.md`](routes/svg-edit.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 修改节点文字/颜色(简单改动)| `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
@@ -70,12 +69,9 @@ metadata:
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
│ ├─ 仅限文字/颜色且不涉及布局变更 → +query --output_as raw → 手动改 JSON → +update --input_format raw(无损)
需要保留/重建语义结构(如思维导图关系、连线绑定)
│ │ → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [routes/svg-edit.md](routes/svg-edit.md)(视觉高保真还原,部分语义有损,需告知用户)
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
└─ 用户有明确要求 → 以用户要求优先
```
@@ -92,8 +88,8 @@ metadata:
| 图表类型 | 身份 | 路径 |
|------------------------|-------------------------------------|------------------------------------------|
| 思维导图、流程图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` / `Doubao` / `Seed` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Other` | [`routes/dsl.md`](routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error
@@ -123,7 +119,7 @@ diagram.png ← 渲染结果
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \

View File

@@ -9,14 +9,13 @@
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as code/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
@@ -39,17 +38,7 @@ lark-cli whiteboard +query \
--output_as code
```
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output ./whiteboard.svg \
--as user
```
### 示例 4导出画板原始节点结构到文件
### 示例 3导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \

View File

@@ -74,7 +74,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--source - --input_format raw \
@@ -88,7 +88,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 生成 OpenAPI 格式到文件
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <DSL 文件> --to openapi --format json -o ./temp.json
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
# 从文件读取并更新
lark-cli whiteboard +update \

View File

@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
先出骨架图导出坐标,再基于坐标补充连线和注解:
```bash
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i skeleton.json -o step1.png -l coords.json
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
```
`coords.json` 包含每个带 id 节点的精确坐标absX, absY, width, height

View File

@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
x?: number; y?: number;
width?: WBSizeValue; // 默认 48
height?: WBSizeValue; // 默认 48保持正方形
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons 输出中选取
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
color?: string; // 可选颜色覆盖hex 格式如 '#FF6600'
}
```
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
```bash
npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
```
用法:

View File

@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
Step 2: 生成完整 DSL含颜色
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons` 查看可用图标
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md排版参考 typography.md
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL含颜色
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.json -o diagram.png
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.json --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 保存为 diagram.mmd
3. 渲染仅用于预览验证PNG 不是最终产物):
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.mmd -o diagram.png
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
4. 审查 PNG有问题修改后重新渲染最多 2 轮)
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.mmd --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -1,103 +0,0 @@
# SVG 编辑路径
通过导出画板的 SVG → 编辑 SVG → 回写画板,实现对已有画板的可视化编辑。
---
## ⚠️ 有损性警告 & 适用判断
SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层级/表格结构/连线绑定/容器类型/mention/节点 ID/锁定/评论/历史)**不可恢复**。
**保留的信息**:形状几何(位置/大小/路径)、文本内容与基本格式(字号/粗体/斜体/对齐)、填充色/描边色/透明度(线性渐变降级为第一个 stop-color 纯色)、连接器路径形状与箭头样式、`<g>` 嵌套的基本分组关系≥2 子元素时重建为 DirectFocusGroup
> **注意**`<path>` 元素会被 path-analyzer 尝试识别为标准形状rect/圆角矩形/椭圆/菱形/三角形/平行四边形);无法识别的复杂路径降级为 SvgIcon视觉保真但不可编辑
| 操作意图 | 可行性 | 说明 |
|---|---|---|
| 修改文字内容 | ✅ | 编辑 `<text>` 元素 |
| 修改颜色/填充 | ✅ | fill/stroke 属性(线性渐变降级为第一个 stop-color |
| 调整位置/大小 | ✅ | 坐标和尺寸属性 |
| 增删独立形状/装饰 | ✅ | 添加或移除 SVG 元素 |
| 修改连线路径走向 | ✅ | path/polyline 路径数据 |
| 修改箭头样式 | ✅ | `<marker>` 定义connector-transformer 恢复箭头类型 |
| 修改描边虚线样式 | ⚠️ | stroke-dasharray 只映射 3 种固定样式(实线/短划线/点线),自定义模式会被近似 |
| 修改字体 | ❌ | 画板硬编码 Noto Sans SCfont-family 导入后无效 |
| 添加 mention/hyperlink | ❌ | 文本语义丢失,无法通过 SVG 注入 |
| 添加外部图片 | ❌ | 仅支持内置 iconSource URL其他 URL 和 data URI 降级为 SvgIcon |
| 调整思维导图层级 | ❌ | 父子层级/布局类型/折叠状态丢失,走[重绘路径](../SKILL.md#修改-workflow) |
| 增减表格行列 | ❌ | 行列/合并单元格结构丢失,暂无可用路径 |
| 重建连接器端点绑定 | ❌ | `startObject`/`endObject` 丢失,回写后连线自由浮动 |
| 修改容器从属关系 | ❌ | Frame/Section/Container 退化为 DirectFocusGroup走[重绘路径](../SKILL.md#修改-workflow) |
---
## Workflow
### 0. 用户确认(强制)
在执行任何编辑前,**必须**向用户说明:
> SVG 编辑只保证视觉层面对齐,画板语义(层级/节点类型/思维导图结构/表格结构/连线绑定/容器类型/mention 等)将不可恢复,是否继续?
**用户未确认前不得执行后续步骤。**
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
--whiteboard-token <TOKEN> \
--output_as svg \
--output <dir>/original.svg \
--as user
```
### 2. 编辑 SVG
在导出的 SVG 上进行修改。参考 [`svg.md` § 画板怎么处理 SVG](./svg.md#画板怎么处理-svg) 了解可识别元素与不支持的装饰特性。
**技术约束**
- 新增文字必须用 `<text>`(不是 `<path>`容器宽度留够CJK ≈ 1em / Latin ≈ 0.6em
- 避免 `skewX` / `skewY` / `matrix(...)` 变换
- 禁止使用 `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>`
**编辑原则**(区别于从零创作):
- **风格一致**:新增/修改元素应匹配导出 SVG 中已有的配色、字号、线宽、间距风格,不引入突兀的视觉差异
- **最小改动**:只修改用户要求的部分,不主动"优化"或重排无关区域
- **结构稳定**:尽量保留原有 `<g>` 层级结构,避免不必要的重组导致分组关系变化
- **连线协调**:连接器端点绑定已丢失,若移动了形状,必须手动同步调整视觉上连接到该形状的 connector path 端点坐标,否则连线会"断开"
- **内部引用完整性**:不要随意删改 `<defs>` 中被 `url(#id)` 引用的元素(`<marker>`/`<linearGradient>` 等)或修改其 `id`,否则引用方会失效
### 3. 渲染审查
```bash
# 渲染 PNG 预览
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -o <dir>/edited.png -f svg
# 几何检查text-overflow / node-overlap
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --check
```
结合 PNG 视觉效果和 `--check` 报告进行调整,有问题则修改 SVG 后重新渲染(最多 2 轮)。
### 4. 写回画板
```bash
# dry-run 探测(输出含 "XX nodes will be deleted" 时需再次向用户确认)
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 用户确认后执行
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
```
> `--overwrite` 必须携带,否则会增量叠加导致内容重复。

View File

@@ -30,12 +30,12 @@
```
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
写文件 <dir>/diagram.svg
渲染 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
```
`npx -y @larksuite/whiteboard-cli@0.1.1-beta --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
## 画板怎么处理 SVG

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
## Layout 规则

View File

@@ -10,7 +10,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -9,7 +9,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- 不适合手动心算坐标
## Layout 规则

View File

@@ -11,6 +11,12 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**。然后阅读 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md),了解会议纪要相关操作。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 适用场景
- "帮我整理这周的会议纪要" / "总结最近的会议" / "生成会议周报"